From 8be6ea1ec9787a45a1a5f4efb697d1a0514972a5 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:18:32 +0000 Subject: [PATCH 01/12] test(agent): bound mock packaging and avoid heredoc pipe deadlock --- .../scripts/test_release_packaging.py | 66 ++++++++++++------- 1 file changed, 42 insertions(+), 24 deletions(-) diff --git a/apps/maple-agent/scripts/test_release_packaging.py b/apps/maple-agent/scripts/test_release_packaging.py index 8b6321eaa..ca955803f 100644 --- a/apps/maple-agent/scripts/test_release_packaging.py +++ b/apps/maple-agent/scripts/test_release_packaging.py @@ -9,6 +9,7 @@ import plistlib import shlex import shutil +from signal import SIGKILL import subprocess import sys import tempfile @@ -383,6 +384,32 @@ def run_packager(self, unsigned=False, failure="", signal=False, search_list=Non log.unlink(missing_ok=True) search_state = self.directory / "keychain-search.json" search_state.write_text(json.dumps(self.search_list if search_list is None else search_list)) + # Bash 5.3 uses a pipe for this heredoc, but the Darwin pipe capacity + # can be smaller than its threshold. Keep this longer mock as a file + # so a command substitution cannot block before Python starts. + security_list = self.directory / "mock-security-list.py" + security_list.write_text("""import json,os,sys +from pathlib import Path +state=Path(os.environ['FIXTURE_KEYCHAIN_SEARCH']) +args=sys.argv[1:] +if args == ['list-keychains','-d','user']: + if os.environ.get('FIXTURE_FAILURE') == 'malformed-search': + print('not a quoted path') + else: + for path in json.loads(state.read_text()): + print(' "'+path+'"') +else: + assert args[:4] == ['list-keychains','-d','user','-s'], args + paths=args[4:] + adding=any('/.macos-release.' in path and path.endswith('/signing.keychain-db') for path in paths) + failure=os.environ.get('FIXTURE_FAILURE') + if failure == 'restore-search' and not adding: + raise SystemExit(1) + state.write_text(json.dumps(paths)) + # Simulate a setter that changed state before reporting failure. + if failure == 'set-search' and adding: + raise SystemExit(1) +""") wrapper = self.directory / "mock-packager.sh" wrapper.write_text(f"""#!/usr/bin/env bash set -euo pipefail @@ -419,29 +446,7 @@ def run_packager(self, unsigned=False, failure="", signal=False, search_list=Non if [[ "$1" == find-identity ]]; then echo '1) 1111111111111111111111111111111111111111 "Developer ID Application: Fixture (TEAM123456)"'; elif [[ "$1" == create-keychain ]]; then touch "${{@:$#}}"; elif [[ "$1" == list-keychains ]]; then - python3 - "$@" <<'PY' -import json,os,sys -from pathlib import Path -state=Path(os.environ['FIXTURE_KEYCHAIN_SEARCH']) -args=sys.argv[1:] -if args == ['list-keychains','-d','user']: - if os.environ.get('FIXTURE_FAILURE') == 'malformed-search': - print('not a quoted path') - else: - for path in json.loads(state.read_text()): - print(' "'+path+'"') -else: - assert args[:4] == ['list-keychains','-d','user','-s'], args - paths=args[4:] - adding=any('/.macos-release.' in path and path.endswith('/signing.keychain-db') for path in paths) - failure=os.environ.get('FIXTURE_FAILURE') - if failure == 'restore-search' and not adding: - raise SystemExit(1) - state.write_text(json.dumps(paths)) - # Simulate a setter that changed state before reporting failure. - if failure == 'set-search' and adding: - raise SystemExit(1) -PY + python3 {shlex.quote(str(security_list))} "$@" fi ;; codesign) if [[ "$1" == --force && "$3" != - ]]; then @@ -483,7 +488,20 @@ def run_packager(self, unsigned=False, failure="", signal=False, search_list=Non "APPLE_ID": "fixture-apple-id", "APPLE_ID_PASSWORD": "fixture-notary-password", "APPLE_TEAM_ID": "TEAM123456", }) - result = subprocess.run(["bash", str(wrapper)], env=env, capture_output=True, text=True) + process = subprocess.Popen(["bash", str(wrapper)], env=env, stdout=subprocess.PIPE, + stderr=subprocess.PIPE, text=True, start_new_session=True) + try: + stdout, stderr = process.communicate(timeout=60) + except subprocess.TimeoutExpired: + # The fixture owns its process group, including nested mock-tool + # children. Reap all of them when it times out, not unrelated tests. + try: + os.killpg(process.pid, SIGKILL) + except ProcessLookupError: + pass + process.communicate() + self.fail("mock macOS packager exceeded its 60-second timeout") + result = subprocess.CompletedProcess(process.args, process.returncode, stdout, stderr) calls = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] return result, calls From a85a74f7f0139b00c03d802e416b9f5c366d49fa Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:38:31 +0000 Subject: [PATCH 02/12] feat(agent): add isolated signed update delivery --- .githooks/pre-commit | 15 +- .github/CODEOWNERS | 2 + .github/workflows/agent-updates-publish.yml | 217 ++++ .github/workflows/agent-updates-tests.yml | 43 + docs/repository-workflows.md | 3 +- flake.nix | 12 +- scripts/ci/agent-updates.sh | 14 + scripts/ci/hook_change_detection.py | 6 + scripts/ci/test_agent_updates_workflows.py | 121 +++ scripts/ci/test_hook_change_detection.py | 65 ++ scripts/ci/test_nix_workflows.py | 2 +- scripts/ci/test_signing_workflows.py | 23 +- services/agent-updates/.gitignore | 2 + services/agent-updates/README.md | 130 +++ services/agent-updates/bun.lock | 210 ++++ services/agent-updates/package.json | 20 + services/agent-updates/publisher/README.md | 115 +++ .../publisher/fixtures/README.md | 8 + .../publisher/fixtures/manifest.json | 1 + .../publisher/fixtures/manifest.minisig | 4 + .../publisher/fixtures/public-test-key.pub | 2 + .../publisher/fixtures/signed-envelope.json | 4 + services/agent-updates/publisher/publisher.py | 855 +++++++++++++++ .../agent-updates/publisher/test_minisign.py | 92 ++ .../agent-updates/publisher/test_publisher.py | 973 ++++++++++++++++++ .../agent-updates/publisher/test_trust.py | 175 ++++ services/agent-updates/src/dry-run.ts | 63 ++ services/agent-updates/src/fixtures.ts | 39 + services/agent-updates/src/index.ts | 2 + services/agent-updates/src/manifest.ts | 194 ++++ services/agent-updates/src/runtime.test.ts | 125 +++ services/agent-updates/src/worker.test.ts | 332 ++++++ services/agent-updates/src/worker.ts | 261 +++++ services/agent-updates/tsconfig.json | 13 + services/agent-updates/wrangler.jsonc | 16 + 35 files changed, 4152 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/agent-updates-publish.yml create mode 100644 .github/workflows/agent-updates-tests.yml create mode 100755 scripts/ci/agent-updates.sh create mode 100644 scripts/ci/test_agent_updates_workflows.py create mode 100644 services/agent-updates/.gitignore create mode 100644 services/agent-updates/README.md create mode 100644 services/agent-updates/bun.lock create mode 100644 services/agent-updates/package.json create mode 100644 services/agent-updates/publisher/README.md create mode 100644 services/agent-updates/publisher/fixtures/README.md create mode 100644 services/agent-updates/publisher/fixtures/manifest.json create mode 100644 services/agent-updates/publisher/fixtures/manifest.minisig create mode 100644 services/agent-updates/publisher/fixtures/public-test-key.pub create mode 100644 services/agent-updates/publisher/fixtures/signed-envelope.json create mode 100644 services/agent-updates/publisher/publisher.py create mode 100644 services/agent-updates/publisher/test_minisign.py create mode 100644 services/agent-updates/publisher/test_publisher.py create mode 100644 services/agent-updates/publisher/test_trust.py create mode 100644 services/agent-updates/src/dry-run.ts create mode 100644 services/agent-updates/src/fixtures.ts create mode 100644 services/agent-updates/src/index.ts create mode 100644 services/agent-updates/src/manifest.ts create mode 100644 services/agent-updates/src/runtime.test.ts create mode 100644 services/agent-updates/src/worker.test.ts create mode 100644 services/agent-updates/src/worker.ts create mode 100644 services/agent-updates/tsconfig.json create mode 100644 services/agent-updates/wrangler.jsonc diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 5e7e51c5c..d624512a8 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -77,8 +77,9 @@ export MAPLE_HOOK_STAGED="$STAGED_FILE" export MAPLE_HOOK_SELECTED="$SELECTED" export MAPLE_HOOK_FULL="${MAPLE_HOOK_FULL:-0}" -# run_component NAME DIR FLAKE_REF SCRIPT [ENV=VALUE ...] +# run_component NAME DIR FLAKE_REF SCRIPT [--bash] [ENV=VALUE ...] # DIR is where the script and `nix develop` run; FLAKE_REF is relative to DIR. +# CI entrypoints may opt into Bash; component hooks use POSIX sh by default. # ENV=VALUE pairs are set for `nix develop` itself, not only for the script: # a flake's shellHook runs before the `-c` command, and the opensecret shell # starts PostgreSQL and writes .env unless OPENSECRET_DEV_* tell it not to. @@ -86,6 +87,11 @@ export MAPLE_HOOK_FULL="${MAPLE_HOOK_FULL:-0}" run_component() { name=$1; dir=$2; flake_ref=$3; script=$4 shift 4 + component_shell=sh + if [ "${1:-}" = "--bash" ]; then + component_shell=bash + shift + fi hook_log "=== $name ===" started=$(date +%s) # Git exports GIT_INDEX_FILE, GIT_DIR, and friends to hooks. They must not @@ -94,10 +100,10 @@ run_component() { # shellcheck disable=SC2086 # GIT_ENV_UNSET is a deliberate word list if [ -n "$NIX_BIN" ]; then (cd "$dir" && env $GIT_ENV_UNSET "$@" "$NIX_BIN" develop --no-update-lock-file "$flake_ref" \ - -c env MAPLE_HOOK_IN_NIX=1 sh "$script") + -c env MAPLE_HOOK_IN_NIX=1 "$component_shell" "$script") else # shellcheck disable=SC2086 - (cd "$dir" && env $GIT_ENV_UNSET MAPLE_HOOK_IN_NIX=0 "$@" sh "$script") + (cd "$dir" && env $GIT_ENV_UNSET MAPLE_HOOK_IN_NIX=0 "$@" "$component_shell" "$script") fi status=$? hook_log "=== $name finished in $(( $(date +%s) - started ))s ===" @@ -116,6 +122,9 @@ fi if hook_selected updates; then run_component updates "$REPO_ROOT" ".#ci" "$REPO_ROOT/services/updates/.githooks/pre-commit" fi +if hook_selected agent_updates; then + run_component agent-updates "$REPO_ROOT" ".#ci" "$REPO_ROOT/scripts/ci/agent-updates.sh" --bash +fi if hook_selected sdk_rust || hook_selected sdk_ts; then run_component sdk "$REPO_ROOT/sdk" "." "$REPO_ROOT/sdk/.githooks/pre-commit" fi diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index cce1379f3..7870d9221 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -28,6 +28,7 @@ /apps/maple-agent/release_profile.rs @AnthonyRonning /apps/maple-agent/release-profiles.json @AnthonyRonning /apps/maple-agent/scripts/ @AnthonyRonning +/apps/maple-agent/crates/maple-agent-updater/ @AnthonyRonning /apps/maple-agent/app/packaging/ @AnthonyRonning /apps/maple-agent/app/macos/ @AnthonyRonning /apps/maple-research/frontend/package.json @AnthonyRonning @@ -75,4 +76,5 @@ # Retain the existing owner boundary for these distribution components. /services/updates/ @AnthonyRonning +/services/agent-updates/ @AnthonyRonning /proxy/ @AnthonyRonning diff --git a/.github/workflows/agent-updates-publish.yml b/.github/workflows/agent-updates-publish.yml new file mode 100644 index 000000000..cf832fe0d --- /dev/null +++ b/.github/workflows/agent-updates-publish.yml @@ -0,0 +1,217 @@ +name: Publish Agent Updates +run-name: Publish Agent ${{ inputs.version }} from verified build ${{ inputs.build_run_id }} + +on: + workflow_dispatch: + inputs: + build_run_id: + description: Exact completed trusted master run with verified Prod packages + required: true + type: string + version: + description: Stable Agent version matching the selected Prod packages + required: true + type: string + notes: + description: Plain-text release notes (must remain identical on retries) + required: true + type: string + include_linux: + description: Include Linux only after its packaged update launch gate passes + required: true + default: false + type: boolean + +permissions: + contents: read + actions: read + attestations: read + +concurrency: + # Operator pointer changes must use this same serialization boundary. + group: agent-updates-production + cancel-in-progress: false + +jobs: + verify: + if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Install pinned Nix + uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 + with: + source-tag: v3.23.3 + github-token: "" + - name: Verify pinned Nix runtime + run: bash scripts/ci/verify-nix-runtime.sh + - name: Require separately approved production setup + env: + ACTIVATED: ${{ vars.AGENT_UPDATES_ENABLED }} + PUBLIC_KEY: ${{ vars.AGENT_UPDATE_PUBLIC_KEY }} + run: | + set -euo pipefail + [ "$ACTIVATED" = true ] || { echo 'Agent update production setup has not been activated by its owner.' >&2; exit 1; } + [ -n "$PUBLIC_KEY" ] || { echo 'Agent public update key is not configured.' >&2; exit 1; } + printf '%s\n' "$PUBLIC_KEY" > public-key.pub + - name: Verify exact build, download final packages and prepare without secrets + env: + GH_TOKEN: ${{ github.token }} + BUILD_RUN_ID: ${{ inputs.build_run_id }} + RELEASE_VERSION: ${{ inputs.version }} + RELEASE_NOTES: ${{ inputs.notes }} + INCLUDE_LINUX: ${{ inputs.include_linux }} + run: | + set -euo pipefail + linux_args=() + if [ "$INCLUDE_LINUX" = true ]; then linux_args=(--linux); fi + nix develop --no-update-lock-file .#ci -c python3 services/agent-updates/publisher/publisher.py trust \ + --run-id "$BUILD_RUN_ID" "${linux_args[@]}" --output trust.json + gh run download "$BUILD_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "$(jq -er .artifacts.macos trust.json)" --dir packages/macos + linux_args=() + if [ "$INCLUDE_LINUX" = true ]; then + gh run download "$BUILD_RUN_ID" --repo "$GITHUB_REPOSITORY" \ + --name "$(jq -er .artifacts.linux trust.json)" --dir packages/linux + linux_args=(--linux packages/linux) + fi + printf '%s' "$RELEASE_NOTES" > notes.txt + nix develop --no-update-lock-file .#ci -c python3 services/agent-updates/publisher/publisher.py prepare \ + --run-id "$BUILD_RUN_ID" --version "$RELEASE_VERSION" --build "$(jq -er .build trust.json)" \ + --macos packages/macos "${linux_args[@]}" --notes-file notes.txt --public-key public-key.pub --output bundle + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: agent-update-prepared-${{ github.run_id }} + path: bundle/ + overwrite: true + if-no-files-found: error + retention-days: 7 + + reserve: + needs: verify + if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' }} + environment: agent-updates-production + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Install pinned Nix + uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 + with: + source-tag: v3.23.3 + github-token: "" + - name: Verify pinned Nix runtime + run: bash scripts/ci/verify-nix-runtime.sh + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: agent-update-prepared-${{ github.run_id }} + path: bundle + - name: Reserve immutable exact manifest and reuse prior signed envelope + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AGENT_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AGENT_R2_SECRET_ACCESS_KEY }} + R2_ACCOUNT: ${{ vars.AGENT_R2_ACCOUNT_ID }} + R2_BUCKET: ${{ vars.AGENT_R2_BUCKET }} + run: | + set -euo pipefail + [[ "$R2_ACCOUNT" =~ ^[0-9a-f]{32}$ ]] + [ -n "$R2_BUCKET" ] + nix develop --no-update-lock-file .#ci -c python3 services/agent-updates/publisher/publisher.py reserve \ + --bundle bundle --public-key bundle/public-key.pub \ + --endpoint "https://${R2_ACCOUNT}.r2.cloudflarestorage.com" --bucket "$R2_BUCKET" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: agent-update-reserved-${{ github.run_id }} + path: bundle/ + overwrite: true + if-no-files-found: error + retention-days: 7 + + sign: + needs: reserve + if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' }} + environment: agent-update-signing + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Install pinned Nix + uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 + with: + source-tag: v3.23.3 + github-token: "" + - name: Verify pinned Nix runtime + run: bash scripts/ci/verify-nix-runtime.sh + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: agent-update-reserved-${{ github.run_id }} + path: bundle + - name: Sign exact reserved manifest with Agent-only key + env: + AGENT_UPDATE_SIGNING_KEY: ${{ secrets.AGENT_UPDATE_SIGNING_KEY }} + run: | + set -euo pipefail + umask 077 + key_file="$(mktemp)" + trap 'rm -f "$key_file"' EXIT + [ -n "$AGENT_UPDATE_SIGNING_KEY" ] + printf '%s\n' "$AGENT_UPDATE_SIGNING_KEY" > "$key_file" + unset AGENT_UPDATE_SIGNING_KEY + nix develop --no-update-lock-file .#ci -c python3 services/agent-updates/publisher/publisher.py sign \ + --bundle bundle --public-key bundle/public-key.pub --secret-key "$key_file" + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 + with: + name: agent-update-signed-${{ github.run_id }} + path: bundle/ + overwrite: true + if-no-files-found: error + retention-days: 7 + + publish: + needs: sign + if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' }} + environment: + name: agent-updates-production + url: https://updates.trymaple.ai/agent/stable/latest.json + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Install pinned Nix + uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 + with: + source-tag: v3.23.3 + github-token: "" + - name: Verify pinned Nix runtime + run: bash scripts/ci/verify-nix-runtime.sh + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 + with: + name: agent-update-signed-${{ github.run_id }} + path: bundle + - name: Upload immutably, conditionally promote, and verify public delivery + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AGENT_R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AGENT_R2_SECRET_ACCESS_KEY }} + R2_ACCOUNT: ${{ vars.AGENT_R2_ACCOUNT_ID }} + R2_BUCKET: ${{ vars.AGENT_R2_BUCKET }} + run: | + set -euo pipefail + [[ "$R2_ACCOUNT" =~ ^[0-9a-f]{32}$ ]] + [ -n "$R2_BUCKET" ] + nix develop --no-update-lock-file .#ci -c python3 services/agent-updates/publisher/publisher.py publish \ + --bundle bundle --public-key bundle/public-key.pub \ + --endpoint "https://${R2_ACCOUNT}.r2.cloudflarestorage.com" --bucket "$R2_BUCKET" \ + --base-url https://updates.trymaple.ai diff --git a/.github/workflows/agent-updates-tests.yml b/.github/workflows/agent-updates-tests.yml new file mode 100644 index 000000000..68e0c38ec --- /dev/null +++ b/.github/workflows/agent-updates-tests.yml @@ -0,0 +1,43 @@ +name: Agent Update Delivery Tests + +permissions: + contents: read + +on: + push: + branches: [master] + paths: + - ".github/workflows/agent-updates-*.yml" + - "services/agent-updates/**" + - "scripts/ci/agent-updates.sh" + - "scripts/ci/test_agent_updates_workflows.py" + - "scripts/ci/_common.sh" + - "flake.nix" + - "flake.lock" + pull_request: + paths: + - ".github/workflows/agent-updates-*.yml" + - "services/agent-updates/**" + - "scripts/ci/agent-updates.sh" + - "scripts/ci/test_agent_updates_workflows.py" + - "scripts/ci/_common.sh" + - "flake.nix" + - "flake.lock" + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + persist-credentials: false + - name: Install pinned Nix + uses: DeterminateSystems/determinate-nix-action@96c5ee93a9dddf2830d31e1da6ec719077336c28 # v3.23.3 + with: + source-tag: v3.23.3 + github-token: "" + - name: Verify pinned Nix runtime + run: bash scripts/ci/verify-nix-runtime.sh + - name: Check isolated Agent delivery + run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/agent-updates.sh diff --git a/docs/repository-workflows.md b/docs/repository-workflows.md index 4723e7aa2..dceaeb5f5 100644 --- a/docs/repository-workflows.md +++ b/docs/repository-workflows.md @@ -9,7 +9,8 @@ root. For everyday setup, use the owning component guide and skill. `./setup-hooks.sh` installs `.githooks/pre-commit`. It classifies staged paths with `scripts/ci/hook_change_detection.py` and runs each affected component's own `.githooks/pre-commit` inside that component's Nix flake, so the tools match -CI: the root `.#ci` shell for Research, Auth, `services/updates/`, and repository +CI: the root `.#ci` shell for Research, Auth, `services/updates/`, the independent +Agent delivery checks in `scripts/ci/agent-updates.sh`, and repository checks; the component flakes for `apps/maple-agent/`, `sdk/`, `proxy/`, and `services/opensecret?submodules=1`. Without Nix it runs the same commands from `PATH` and warns that results may differ. It runs each selected component's format, lint, type-check, and unit-test gates; diff --git a/flake.nix b/flake.nix index b5e8db9f4..d15d5d13a 100644 --- a/flake.nix +++ b/flake.nix @@ -743,7 +743,7 @@ ''; change-detection = pkgs.runCommand "maple-ci-change-detection-check" { - nativeBuildInputs = [ pkgs.python3 ]; + nativeBuildInputs = with pkgs; [ bash git python3 ]; src = ./.; } '' cd "$src" @@ -790,6 +790,16 @@ touch "$out" ''; + agent-updates = pkgs.runCommand "maple-agent-updates-check" { + nativeBuildInputs = with pkgs; [ git minisign python3 yq-go ]; + src = ./.; + } '' + cd "$src" + python3 scripts/ci/test_agent_updates_workflows.py + python3 -I -m unittest discover -s services/agent-updates/publisher -p 'test_*.py' + touch "$out" + ''; + ios-build-profile = pkgs.runCommand "maple-ios-build-profile-check" { nativeBuildInputs = with pkgs; [ bash python3 unzip ]; src = ./.; diff --git a/scripts/ci/agent-updates.sh b/scripts/ci/agent-updates.sh new file mode 100755 index 000000000..b4c9de1c1 --- /dev/null +++ b/scripts/ci/agent-updates.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +# shellcheck source=_common.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" + +print_source_provenance +export WRANGLER_SEND_METRICS=false +cd "${REPO_ROOT}" +python3 scripts/ci/test_agent_updates_workflows.py +python3 -I -m unittest discover -s services/agent-updates/publisher -p 'test_*.py' +cd services/agent-updates +bun install --frozen-lockfile --ignore-scripts +bun run check diff --git a/scripts/ci/hook_change_detection.py b/scripts/ci/hook_change_detection.py index 862ce201d..711a5dc45 100644 --- a/scripts/ci/hook_change_detection.py +++ b/scripts/ci/hook_change_detection.py @@ -23,6 +23,7 @@ "proxy", "opensecret", "updates", + "agent_updates", "repo", ) ALL_OUTPUTS = frozenset(OUTPUTS) @@ -95,6 +96,7 @@ ) UPDATES_PREFIX = "services/updates/" +AGENT_UPDATES_PREFIX = "services/agent-updates/" REPO_PREFIXES = (".github/", "scripts/ci/") REPO_FILES = frozenset({"flake.nix", "flake.lock"}) @@ -161,6 +163,10 @@ def classify_path(path: str) -> frozenset[str]: return frozenset() if path.startswith(UPDATES_PREFIX): return frozenset({"updates"}) + if path.startswith(AGENT_UPDATES_PREFIX + ".githooks/"): + return frozenset() + if path.startswith(AGENT_UPDATES_PREFIX): + return frozenset({"agent_updates"}) if path in REPO_FILES or path.startswith(REPO_PREFIXES): return frozenset({"repo"}) diff --git a/scripts/ci/test_agent_updates_workflows.py b/scripts/ci/test_agent_updates_workflows.py new file mode 100644 index 000000000..9bebeda1c --- /dev/null +++ b/scripts/ci/test_agent_updates_workflows.py @@ -0,0 +1,121 @@ +"""Keep Agent data publication separate from Research and signing authority.""" + +import functools +import json +from pathlib import Path +import re +import subprocess +import unittest + + +ROOT = Path(__file__).resolve().parents[2] +MASTER = "github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch'" + + +@functools.cache +def workflow(name): + return json.loads(subprocess.check_output( + ["yq", "-o=json", ".", str(ROOT / ".github/workflows" / name)], text=True, + )) + + +class AgentUpdatesWorkflows(unittest.TestCase): + def test_manual_master_only_and_separate_authority(self): + config = workflow("agent-updates-publish.yml") + self.assertEqual(set(config["on"]), {"workflow_dispatch"}) + self.assertEqual(config["permissions"], { + "contents": "read", "actions": "read", "attestations": "read", + }) + self.assertEqual(config["concurrency"], { + "group": "agent-updates-production", "cancel-in-progress": False, + }) + jobs = config["jobs"] + self.assertEqual(set(jobs), {"verify", "reserve", "sign", "publish"}) + self.assertNotIn("environment", jobs["verify"]) + self.assertEqual(jobs["reserve"]["needs"], "verify") + self.assertEqual(jobs["sign"]["needs"], "reserve") + self.assertEqual(jobs["publish"]["needs"], "sign") + for name, job in jobs.items(): + self.assertIn(MASTER, " ".join(job["if"].split())) + environment = job.get("environment") + if isinstance(environment, dict): + environment = environment["name"] + if name == "sign": + self.assertEqual(environment, "agent-update-signing") + elif name in {"reserve", "publish"}: + self.assertEqual(environment, "agent-updates-production") + self.assertNotIn("permissions", job) + for step in job["steps"]: + self.assertNotIn("continue-on-error", step) + for value in step.get("env", {}).values(): + if "secrets." in str(value): + secret = re.search(r"secrets\.([A-Z0-9_]+)", value)[1] + self.assertIn(secret, {"AGENT_UPDATE_SIGNING_KEY"} if name == "sign" else { + "AGENT_R2_ACCESS_KEY_ID", "AGENT_R2_SECRET_ACCESS_KEY", + } if name in {"reserve", "publish"} else set()) + if step.get("uses", "").startswith("actions/checkout@"): + self.assertEqual(step["with"]["ref"], "${{ github.sha }}") + self.assertIs(step["with"]["persist-credentials"], False) + + def test_preparation_verifies_provenance_before_any_secrets(self): + verify = workflow("agent-updates-publish.yml")["jobs"]["verify"] + source = "\n".join(step.get("run", "") for step in verify["steps"]) + self.assertIn("AGENT_UPDATES_ENABLED", str(verify)) + self.assertIn('"$ACTIVATED" = true', source) + self.assertIn("publisher.py trust", source) + self.assertIn("publisher.py prepare", source) + self.assertIn("--public-key public-key.pub", source) + self.assertIn("--version", source) + self.assertIn("--run-id", source) + self.assertIn("--linux", source) + self.assertNotIn("secrets.", str(verify)) + self.assertNotIn("--unsigned", source) + self.assertNotIn("gh release", source) + + def test_retry_transfers_keep_stable_artifact_names(self): + jobs = workflow("agent-updates-publish.yml")["jobs"] + for producer, consumer in (("verify", "reserve"), ("reserve", "sign"), ("sign", "publish")): + upload = next(step["with"] for step in jobs[producer]["steps"] + if step.get("uses", "").startswith("actions/upload-artifact@")) + download = next(step["with"] for step in jobs[consumer]["steps"] + if step.get("uses", "").startswith("actions/download-artifact@")) + self.assertEqual(upload["name"], download["name"]) + self.assertNotIn("run_attempt", upload["name"]) + self.assertIs(upload["overwrite"], True) + self.assertEqual(upload["if-no-files-found"], "error") + + def test_credentialed_steps_never_execute_packages_or_deploy(self): + jobs = workflow("agent-updates-publish.yml")["jobs"] + for name in ("reserve", "sign", "publish"): + source = "\n".join(step.get("run", "") for step in jobs[name]["steps"]) + self.assertNotRegex(source, r"(?:\.AppImage|tar\s|hdiutil|codesign|package-release|verify-release|gh\s+release|git\s+(?:push|tag)|wrangler|workers/domains)") + self.assertNotIn("services/updates", source) + self.assertNotIn("CLOUDFLARE_API_TOKEN", source) + publish = str(jobs["publish"]) + self.assertIn("--base-url https://updates.trymaple.ai", publish) + self.assertNotIn("AGENT_UPDATES_ORIGIN", publish) + + def test_routes_and_worker_resources_require_separate_setup(self): + source = (ROOT / "services/agent-updates/wrangler.jsonc").read_text() + config = json.loads(re.sub(r",(\s*[}\]])", r"\1", re.sub(r"//[^\n]*", "", source))) + self.assertFalse(config["workers_dev"]) + self.assertFalse(config["preview_urls"]) + self.assertNotIn("routes", config) + self.assertNotIn("route", config) + self.assertNotIn("assets", config) + self.assertNotIn("env", config) + self.assertEqual(config["r2_buckets"], [{ + "binding": "AGENT_RELEASES", "bucket_name": "REQUIRES-OWNER-APPROVED-AGENT-BUCKET", + }]) + # Keep an accidental deploy out of the ordinary package commands. + package = json.loads((ROOT / "services/agent-updates/package.json").read_text()) + self.assertNotIn("deploy", package["scripts"]) + self.assertEqual(package["scripts"]["build:dry-run"], "bun src/dry-run.ts") + dry_run = (ROOT / "services/agent-updates/src/dry-run.ts").read_text() + self.assertIn('"--dry-run"', dry_run) + self.assertIn('"deploy"', dry_run) + self.assertIn('bucket_name: "agent-updates-local-dry-run"', dry_run) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_hook_change_detection.py b/scripts/ci/test_hook_change_detection.py index 478b314b7..8dc310244 100644 --- a/scripts/ci/test_hook_change_detection.py +++ b/scripts/ci/test_hook_change_detection.py @@ -1,6 +1,11 @@ #!/usr/bin/env python3 """Unit tests for the pre-commit hook component selector.""" +import os +from pathlib import Path +import shutil +import subprocess +import tempfile import unittest from hook_change_detection import OUTPUTS, classify_paths @@ -38,6 +43,7 @@ def test_documentation_and_hook_metadata_select_nothing(self) -> None: "services/opensecret/AGENTS.md", "services/opensecret/.githooks/pre-commit", "services/updates/.githooks/pre-commit", + "services/agent-updates/.githooks/pre-commit", "services/opensecret/docs/nitro-deploy.md", "services/opensecret/pcrProd.json", "scripts/prepare-frontend-deps.sh", @@ -91,6 +97,17 @@ def test_proxy_opensecret_and_updates(self) -> None: self.assert_selects(["services/opensecret/nix/eif.nix"]) self.assert_selects(["services/updates/src/index.ts"], "updates") + def test_agent_updates_component_is_isolated_from_research(self) -> None: + for path in ( + "services/agent-updates/src/worker.ts", + "services/agent-updates/publisher/publisher.py", + "services/agent-updates/publisher/test_publisher.py", + "services/agent-updates/bun.lock", + "services/agent-updates/wrangler.jsonc", + ): + with self.subTest(path=path): + self.assert_selects([path], "agent_updates") + def test_shared_crates_do_not_fan_out_to_consumers(self) -> None: self.assert_selects(["proxy/src/proxy.rs", "sdk/rust/src/client.rs"], "proxy", "sdk_rust") self.assert_selects( @@ -108,5 +125,53 @@ def test_unknown_root_selects_everything(self) -> None: self.assert_selects(["newservice/main.rs"], *OUTPUTS) +class AgentUpdatesHookTests(unittest.TestCase): + def test_service_paths_run_the_bash_ci_entrypoint_in_the_root_shell(self) -> None: + source = Path(__file__).resolve().parents[2] + for use_nix in (True, False): + with self.subTest(nix=use_nix), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) / "repository with spaces" + root.mkdir() + for relative in ( + ".githooks/pre-commit", ".githooks/lib/common.sh", + "scripts/ci/hook_change_detection.py", + ): + destination = root / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source / relative, destination) + script = root / "scripts/ci/agent-updates.sh" + script.write_text( + '#!/usr/bin/env bash\nset -euo pipefail\n' + 'selected=("$MAPLE_HOOK_SELECTED")\n' + 'printf "%s\\n%s\\n%s\\n" "${selected[0]}" "$PWD" ' + '"$MAPLE_HOOK_IN_NIX" > hook-result\n' + ) + tools = root / "tools" + tools.mkdir() + nix = tools / "nix" + nix.write_text( + '#!/bin/sh\n' + '[ "$1" = develop ] && [ "$2" = --no-update-lock-file ] ' + '&& [ "$3" = ".#ci" ] && [ "$4" = -c ] || exit 70\n' + 'shift 4\nexec "$@"\n' + ) + nix.chmod(0o755) + changed = root / "services/agent-updates/src/worker.ts" + changed.parent.mkdir(parents=True) + changed.write_text("export default {};\n") + environment = {key: value for key, value in os.environ.items() + if not key.startswith(("GIT_", "MAPLE_HOOK_"))} + environment["PATH"] = f"{tools}{os.pathsep}{environment['PATH']}" + environment["MAPLE_HOOK_NO_NIX"] = "0" if use_nix else "1" + subprocess.run(["git", "init", "-q"], cwd=root, env=environment, check=True) + subprocess.run(["git", "add", str(changed)], cwd=root, env=environment, check=True) + subprocess.run(["sh", ".githooks/pre-commit"], cwd=root, env=environment, + check=True, capture_output=True, text=True) + selected, directory, in_nix = (root / "hook-result").read_text().splitlines() + self.assertEqual(selected.strip(), "agent_updates") + self.assertEqual(Path(directory).resolve(), root.resolve()) + self.assertEqual(in_nix, "1" if use_nix else "0") + + if __name__ == "__main__": unittest.main() diff --git a/scripts/ci/test_nix_workflows.py b/scripts/ci/test_nix_workflows.py index c020d8ee7..eef5be86c 100644 --- a/scripts/ci/test_nix_workflows.py +++ b/scripts/ci/test_nix_workflows.py @@ -18,7 +18,7 @@ "name": "Verify pinned Nix runtime", "run": "bash scripts/ci/verify-nix-runtime.sh", } -EXPECTED_INSTALLS = 69 +EXPECTED_INSTALLS = 74 @functools.cache diff --git a/scripts/ci/test_signing_workflows.py b/scripts/ci/test_signing_workflows.py index 60d957abe..07957695e 100644 --- a/scripts/ci/test_signing_workflows.py +++ b/scripts/ci/test_signing_workflows.py @@ -36,10 +36,12 @@ "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", }, "zapstore-publishing": {"ZAPSTORE_SIGN_WITH"}, + "agent-update-signing": {"AGENT_UPDATE_SIGNING_KEY"}, } SIGNING_SECRETS = set.union(*ENVIRONMENTS.values()) CONSUMERS = { "agent-desktop-build.yml": {"macos": "desktop-signing"}, + "agent-updates-publish.yml": {"sign": "agent-update-signing"}, "desktop-build.yml": { "build-macos": "desktop-signing", "build-linux": "desktop-signing", "build-windows": "windows-signing", @@ -56,7 +58,12 @@ "build-android": "android-signing", "build-ios": "apple-signing", }, "zapstore-publish.yml": {"publish": "zapstore-publishing"}, - "signing-credentials-check.yml": {name: name for name in ENVIRONMENTS}, + "signing-credentials-check.yml": { + name: name for name in ( + "desktop-signing", "apple-signing", "android-signing", + "windows-signing", "zapstore-publishing", + ) + }, } @@ -283,6 +290,20 @@ def test_missing_wrong_and_dynamic_environments_fail_closed(self): check_boundary({**reference, "environment": environment}) check_boundary({**reference, "environment": "apple-signing"}) + def test_agent_update_key_is_exclusive_to_its_publisher_signing_job(self): + consumers = [] + for name, config in workflows().items(): + for job_name, job in config["jobs"].items(): + if "AGENT_UPDATE_SIGNING_KEY" in signing_references(job): + consumers.append((name, job_name)) + self.assertEqual(consumers, [("agent-updates-publish.yml", "sign")]) + reference = {"steps": [{"env": {"KEY": "${{ secrets.AGENT_UPDATE_SIGNING_KEY }}"}}]} + for environment in (None, "desktop-signing", "agent-updates-production", + "${{ inputs.environment }}"): + with self.subTest(environment=environment), self.assertRaises(ValueError): + check_boundary({**reference, "environment": environment}) + check_boundary({**reference, "environment": "agent-update-signing"}) + if __name__ == "__main__": unittest.main() diff --git a/services/agent-updates/.gitignore b/services/agent-updates/.gitignore new file mode 100644 index 000000000..0dcc8a419 --- /dev/null +++ b/services/agent-updates/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +.wrangler/ diff --git a/services/agent-updates/README.md b/services/agent-updates/README.md new file mode 100644 index 000000000..c910d35bf --- /dev/null +++ b/services/agent-updates/README.md @@ -0,0 +1,130 @@ +# Maple Agent update delivery + +Independent Agent Worker and R2 publisher. Research's `services/updates/`, +`maple-updates` Worker, domain mapping, publishing workflow and installed clients +are unchanged. This code creates no Cloudflare resources and starts no releases. +See [publisher tooling](publisher/README.md) for exact commands and retry behavior. + +The checked-in bucket binding is deliberately a placeholder. There is no deploy +script or automatic deployment workflow. `workers_dev` and preview URLs are off; +routine configuration must never contain `route`, `routes` or a Custom Domain. + +## Local verification + +From the monorepo root: + +```sh +nix develop --no-update-lock-file .#ci -c ./scripts/ci/agent-updates.sh +``` + +This runs schema and Worker tests, real workerd/R2 fixture requests, publisher +failure/retry tests and a deploy dry run. Fixtures use fake signatures: they +prove delivery and parsing, while publisher tests and the client own pinned-key +authentication. None of these tests reads a production bucket or executes a +downloaded app. Source checks do not establish Cloudflare permissions or a +successful packaged update. + +## Public contract + +Only GET and HEAD are supported. Metadata and errors use `no-store` at both +browser and CDN layers. No origin fetch is made on failure. + +| Path | Behavior | +| ---------------------------------------- | -------------------------------------------------------------------------------- | +| `/agent/stable/latest.json` | Exact signed envelope from `stable/latest.json` | +| `/agent/stable/releases/V/manifest.json` | Immutable signed envelope | +| `/agent/stable/releases/V/FILE` | Signed, enumerated artifact; GET/HEAD, single byte ranges, ETag, immutable cache | +| `/download/agent/stable/macos` | Uncached 302 to the current DMG | +| `/download/agent/stable/linux-appimage` | Uncached 302 only when Linux is present | + +Missing objects/targets return 404. Malformed metadata, mismatched stored sizes +and bucket failures return bounded 503 responses. The Worker checks schema and +minisign structure; it has no signing key. Clients must authenticate exact +manifest bytes with their pinned keys before trusting fields or hashes. + +The envelope has exactly `manifest` (JSON text) and `signature` (minisign text). +The manifest has `format: maple-agent-update-v1`, `product: maple-agent`, +`channel: stable`, canonical stable SemVer `version`, positive decimal-string +`build` (up to 18 digits), lowercase `source_sha`, UTC second-resolution +`pub_date`, plain-text `notes`, `artifacts`, and `downloads`. + +`artifacts.darwin-aarch64` is the `.app.tar.gz`; `downloads.macos` is the DMG. +Optional `artifacts.linux-x86_64` and `downloads.linux-appimage` must describe the +same `.AppImage`. Each artifact contains exactly `path`, positive `size` (at most +4 GiB) and lowercase SHA-256 `sha256`. Paths must start +`/agent/stable/releases//` and end in one ASCII filename using letters, +digits, `.`, `_`, `-`; traversal and encoded separators are rejected. Supporting +build-info and provenance objects in R2 are private, not public artifact routes. + +Limits: 128 KiB envelope, 64 KiB manifest, 4 KiB minisign signature, 16 KiB notes. +Signature format is prehashed minisign Ed25519 with exact trusted comment +`product=maple-agent channel=stable version=`. No expiry is claimed. + +## Publication and authority + +`agent-updates-publish.yml` is manual, trusted-master-only, and serialized with +operator pointer changes through `agent-updates-production`. It selects an exact +completed trusted-master `agent-desktop-build.yml` run, requires the successful +Prod producers and final download verifiers for every included platform (not +merely overall workflow success), verifies every final file's +GitHub attestation against master and the source commit, and validates build +profile, version, inventory and hashes before receiving signing/bucket secrets. +Unrelated Dev or omitted-Linux failures do not block a verified macOS-only +publication. Downloaded executable/package content is never executed in +credentialed jobs. + +Publication uses create-only immutable objects, reuses identical bytes, resumes +missing objects and rejects differing bytes for a reserved version. The complete +signed manifest is created after payload verification. Only the latest pointer +is mutable, using an atomic compare-and-swap. A retry of the identical promoted +release verifies it as a no-op, including after a failed live verification. +Never delete a reserved release to make a conflicting retry pass. Publish a new +version or resolve the exact failure with the owner. + +The owner must prepare and approve these inputs before activating delivery: + +- A private existing Agent bucket, no `r2.dev`/bucket custom domain, retention + and immutable-object overwrite/delete policy protecting release prefixes. +- Separate Agent-only macOS package signing, update-signing and R2 publication + environments with required reviewers and master-only branch policy. Signing + uses new Agent primary/offline backup keys; never Research's key. +- Bucket-scoped S3 Object Read & Write credentials, an approved account/bucket, + public primary key and exact HTTPS origin. R2 credentials cannot deploy Workers. +- An existing Agent Worker and scoped Worker Editor deployment credential; + confirm a pinned code deployment with the R2 binding preserves Routes. No + Route or Custom Domain management is part of routine code deployment. +- Exactly `https://updates.trymaple.ai/agent/stable/*` and + `https://updates.trymaple.ai/download/agent/stable/*`, separately owner-managed. + Confirm zone/account/proxied DNS, HTTP redirects and edge allowlisting first. + +Workflow setup is explicit and initially absent: + +| Scope | Required input | +| ------------------------------------------------ | ----------------------------------------------------------------------------- | +| Repository variables | `AGENT_UPDATES_ENABLED=true`, `AGENT_UPDATE_PUBLIC_KEY` (public minisign key) | +| `agent-updates-production` environment variables | `AGENT_R2_ACCOUNT_ID`, `AGENT_R2_BUCKET` | +| `agent-updates-production` secrets | `AGENT_R2_ACCESS_KEY_ID`, `AGENT_R2_SECRET_ACCESS_KEY` | +| `agent-update-signing` secret | `AGENT_UPDATE_SIGNING_KEY` (dedicated unencrypted minisign key-file contents) | + +The public key must also be pinned in both the chosen build's Prod source profile +and the publisher's trusted master profile. An empty source key list fails closed; +setting workflow variables cannot replace those pins. Production verification uses +the fixed `https://updates.trymaple.ai` origin. No Worker deployment token belongs +in any publication environment. Protect both environments with required reviewers +and master-only branch policy before activation. + +Before/after route activation compare Research's metadata bytes and all six +GET/HEAD download redirects, accounting for legitimate Research releases. In an +isolated rehearsal verify Research-style Worker redeploy/domain reattachment +preserves Agent routes; recheck after the next authorized Research publication. +Do not create a Research release for this check. + +An unadvertised signed `0.0.1` to `0.0.2` rehearsal through real packages, feed, +keys and UI is required before invitations. Include Linux only after its own +packaged launch/update/relaunch gates pass. This service does not prove those +client gates or authorize a deploy/publication. + +Pointer rollback changes discovery, not installed apps. Install-time client +revalidation is required. Freeze/replay protection is limited in v1; a compromised +accepted key may require manual trusted reinstall. Keep the offline backup +separate and document key retirement/manual recovery before production signing. diff --git a/services/agent-updates/bun.lock b/services/agent-updates/bun.lock new file mode 100644 index 000000000..6d751a6d5 --- /dev/null +++ b/services/agent-updates/bun.lock @@ -0,0 +1,210 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@trymaple/agent-updates-worker", + "devDependencies": { + "@types/bun": "1.3.13", + "prettier": "3.9.6", + "typescript": "5.9.3", + "wrangler": "4.125.0", + }, + }, + }, + "packages": { + "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260820.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-5F2/t7SVnugG3rscSe9da1LoHst+GiuVGPaE9BP6j5AonlFpiYi0eoJrl3wof9zDBIIgJZ87NjRj9TKjbbYgHg=="], + + "@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260820.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jFnG7715+r9FXRZPpuWWe5Ayd9v/IJKDODARg56ffFJWWtte6bFNi5VY3GazBM04hEmxny6OjXQBh3j2E/wNZw=="], + + "@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260820.1", "", { "os": "linux", "cpu": "x64" }, "sha512-TbTYaCBht0OaOWmnVpg43hXVYtIti/Kg6lvO819H2DwbxPpK1BH0z2C+Y7ySrVJLlxZQeic5eN7/noqbP80hJg=="], + + "@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260820.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-FQmri1UF7hBnnpeyC5SZJCAnsSRs3+Ykn9wlO5zxmE2qIgKfbJZ+toJ6qrQyugWlxE65juT33HU6aYLtutLJHw=="], + + "@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260820.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BPvCuMxIQfA47wtYsGbBG6Bcar57Qs7yHqU2jWkT1+sRnL/741/ZbQGP9kVRMQ5fwBMuqNFmQRzAu8gSm7ri/w=="], + + "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.3.1" }, "os": "darwin", "cpu": "arm64" }, "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.3.1" }, "os": "darwin", "cpu": "x64" }, "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw=="], + + "@img/sharp-freebsd-wasm32": ["@img/sharp-freebsd-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "os": "freebsd" }, "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.3.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.3.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.3.1", "", { "os": "linux", "cpu": "arm" }, "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.3.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.3.1", "", { "os": "linux", "cpu": "none" }, "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.3.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.3.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.3.1", "", { "os": "linux", "cpu": "x64" }, "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.3.1" }, "os": "linux", "cpu": "arm" }, "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.3.1" }, "os": "linux", "cpu": "ppc64" }, "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.3.1" }, "os": "linux", "cpu": "none" }, "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.3.1" }, "os": "linux", "cpu": "s390x" }, "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" }, "os": "linux", "cpu": "arm64" }, "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.35.2", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.3.1" }, "os": "linux", "cpu": "x64" }, "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.35.2", "", { "dependencies": { "@emnapi/runtime": "^1.11.1" } }, "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw=="], + + "@img/sharp-webcontainers-wasm32": ["@img/sharp-webcontainers-wasm32@0.35.2", "", { "dependencies": { "@img/sharp-wasm32": "0.35.2" }, "cpu": "none" }, "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.35.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.35.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.35.2", "", { "os": "win32", "cpu": "x64" }, "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], + + "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], + + "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + + "esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + + "miniflare": ["miniflare@5.20260820.0-alpha", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.29.0", "workerd": "1.20260820.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" } }, "sha512-Bv1j2kcKKNwXLWuCx+j0xGt7z318mqQkJmEN6ellM9sCESbPBDTM9ofZMbKqx47jSnoGA3CiaUkAmzGVXUa/wQ=="], + + "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], + + "semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + + "sharp": ["sharp@0.35.2", "", { "dependencies": { "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", "semver": "^7.8.4" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.35.2", "@img/sharp-darwin-x64": "0.35.2", "@img/sharp-freebsd-wasm32": "0.35.2", "@img/sharp-libvips-darwin-arm64": "1.3.1", "@img/sharp-libvips-darwin-x64": "1.3.1", "@img/sharp-libvips-linux-arm": "1.3.1", "@img/sharp-libvips-linux-arm64": "1.3.1", "@img/sharp-libvips-linux-ppc64": "1.3.1", "@img/sharp-libvips-linux-riscv64": "1.3.1", "@img/sharp-libvips-linux-s390x": "1.3.1", "@img/sharp-libvips-linux-x64": "1.3.1", "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", "@img/sharp-libvips-linuxmusl-x64": "1.3.1", "@img/sharp-linux-arm": "0.35.2", "@img/sharp-linux-arm64": "0.35.2", "@img/sharp-linux-ppc64": "0.35.2", "@img/sharp-linux-riscv64": "0.35.2", "@img/sharp-linux-s390x": "0.35.2", "@img/sharp-linux-x64": "0.35.2", "@img/sharp-linuxmusl-arm64": "0.35.2", "@img/sharp-linuxmusl-x64": "0.35.2", "@img/sharp-webcontainers-wasm32": "0.35.2", "@img/sharp-win32-arm64": "0.35.2", "@img/sharp-win32-ia32": "0.35.2", "@img/sharp-win32-x64": "0.35.2" } }, "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w=="], + + "supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + + "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "workerd": ["workerd@1.20260820.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260820.1", "@cloudflare/workerd-darwin-arm64": "1.20260820.1", "@cloudflare/workerd-linux-64": "1.20260820.1", "@cloudflare/workerd-linux-arm64": "1.20260820.1", "@cloudflare/workerd-windows-64": "1.20260820.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-/wk4rFNHH6IVMXFe6aPEZsT5YWpWtfKTUMt7+rFkyeGbXcGCy4yxODmJatbqYj0jmqHETdz0+m2mT+vNjXnF7w=="], + + "wrangler": ["wrangler@4.125.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "5.20260820.0-alpha", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260820.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260820.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js", "cf-wrangler": "bin/cf-wrangler.js" } }, "sha512-yFpvggu+xk1Hdm/Uxwaqa19bb7GArME4CrCS3Vov68a2TZq2MPO+wLocKbbnIC9K0oLowcdau7/ycxbbNHKCEg=="], + + "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + } +} diff --git a/services/agent-updates/package.json b/services/agent-updates/package.json new file mode 100644 index 000000000..774d367b2 --- /dev/null +++ b/services/agent-updates/package.json @@ -0,0 +1,20 @@ +{ + "name": "@trymaple/agent-updates-worker", + "version": "0.0.0", + "private": true, + "packageManager": "bun@1.3.5", + "scripts": { + "check": "bun run format:check && bun run typecheck && bun run test && bun run build:dry-run", + "build:dry-run": "bun src/dry-run.ts", + "format": "prettier --write \"src/**/*.ts\" README.md package.json tsconfig.json wrangler.jsonc", + "format:check": "prettier --check \"src/**/*.ts\" README.md package.json tsconfig.json wrangler.jsonc", + "test": "bun --no-env-file test", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/bun": "1.3.13", + "prettier": "3.9.6", + "typescript": "5.9.3", + "wrangler": "4.125.0" + } +} diff --git a/services/agent-updates/publisher/README.md b/services/agent-updates/publisher/README.md new file mode 100644 index 000000000..9351d846a --- /dev/null +++ b/services/agent-updates/publisher/README.md @@ -0,0 +1,115 @@ +# Agent release publisher + +This tooling publishes the independent Agent feed. It does not change Research, +create Cloudflare resources, generate keys, create GitHub releases or execute +downloaded applications. Run it from a trusted checkout with the root pinned +`nix develop --no-update-lock-file .#ci` shell (Python, GitHub CLI and minisign). + +The workflow separates read-only package verification, bucket reservation, +signing and publication. Reservation/publication use bucket-scoped S3 credentials; +the signing stage receives only a dedicated unencrypted minisign secret-key file. +The public key must already be pinned both in the selected build's source profile +and in the publisher's trusted source profile. An empty key list blocks publication. + +```sh +publisher=services/agent-updates/publisher/publisher.py +python3 "$publisher" trust --run-id BUILD_RUN_ID --output trust.json +# Use --linux on trust and --linux linux-artifacts on prepare only when the +# Linux package and installed-update acceptance gates have passed. +python3 "$publisher" prepare --run-id BUILD_RUN_ID --version 0.0.1 \ + --build BUILD_RUN_NUMBER --macos macos-artifacts --notes-file notes.txt \ + --public-key public-key.pub --output bundle +python3 "$publisher" reserve --bundle bundle --public-key bundle/public-key.pub \ + --endpoint https://ACCOUNT.r2.cloudflarestorage.com --bucket AGENT_BUCKET +python3 "$publisher" sign --bundle bundle --public-key bundle/public-key.pub \ + --secret-key /private/temporary/signing-key +python3 "$publisher" publish --bundle bundle --public-key bundle/public-key.pub \ + --endpoint https://ACCOUNT.r2.cloudflarestorage.com --bucket AGENT_BUCKET \ + --base-url https://updates.trymaple.ai +``` + +`trust` returns the source SHA, selected artifact names, exact run attempt, +publication date and build number as JSON. Only completed trusted master runs of +`agent-desktop-build.yml` qualify. The selected Prod signing/package and downloaded +consumer validation jobs must have succeeded; an aggregate success or skipped job +is insufficient, while an unrelated Dev failure does not block a qualified Prod +package. The latest job inventory retains successful producer jobs when only +failed consumers were rerun. `prepare` repeats this check and verifies every final downloaded file's +GitHub attestation against the exact source SHA, master ref and signer workflow. +Package metadata, public Prod profile, inventory, notarization results and streaming +checksums are validated before copying the binary payloads into the bundle. The +macOS archive's bounded regular `Info.plist` member is read without extraction to +match the selected run's build number, version and application identity. Sparse +archives are rejected; extension headers and cumulative metadata are bounded. + +The manifest build number is the selected build run's `run_number`, matching the +packaging workflow's `CFBundleVersion` input. Its application version must match +the selected packages and be a canonical stable SemVer. The default publication +date is the build run's creation date, making preparation deterministic across +retries. `--pub-date` can select another UTC date, but retries must preserve it, +the notes and every other reserved byte. No timestamps are inserted when signing. + +`reserve` creates `stable/releases/V/publication-manifest.json` using +`If-None-Match: *`. This private object reserves the exact unsigned bytes before +the signing stage. It also reserves `publication-metadata.json`, an exact hash and +size inventory of the private audit sidecars. Both digests are bound by the +reservation receipt. It recovers an existing complete signed manifest into the +bundle, preserving its envelope on a retry. `sign` requires the reservation +receipt, signs the exact bytes with prehashed minisign and the exact trusted +comment `product=maple-agent channel=stable version=V`, then verifies the signature. + +`publish` revalidates the envelope and local payload hashes, confirms the durable +reservation and snapshots the current feed's ETag. It creates immutable payloads, +and the bounded private audit sidecars, verifies each by reading its content back, +then creates the complete signed `stable/releases/V/manifest.json` and promotes +`stable/latest.json` using `If-Match` (or `If-None-Match: *` for the first release). +Existing immutable content is streamed and reused only if its bytes match. A +partially uploaded release resumes without replacing completed objects. A +concurrent pointer change stops promotion. Re-running an already published exact +release verifies the objects, repairs missing objects with create-only writes and +leaves the pointer unchanged; a different envelope at the same or lower version is +rejected. Failed attempts never delete objects or automatically restore a pointer. + +The bundle contains raw, provenance-checked build-info, package manifest, +SHA256SUMS and macOS notarization logs under `metadata/`, prefixed by platform, +plus the stable run/source/artifact identity and attestation policy in +`provenance.json`. The current diagnostic run attempt stays in CI's `trust.json`; +it is omitted from durable metadata so a successful verifier-only rerun can reuse +the exact release. A finite filename allowlist and a 4 MiB limit per file +bound this audit inventory. Reservation, signing and publication require exactly +the five macOS sidecars and provenance, plus the three Linux sidecars when Linux +is included; a missing, empty, partial or extra inventory is rejected. These +objects are stored immutably under +`stable/releases/V/metadata/` and verified before the complete manifest appears. +They remain private because the Worker serves only artifacts enumerated in the +signed wire manifest. The publication reservation and metadata inventory are also +private. Missing sidecars can be repaired on an identical retry; differing bytes +are a conflict. Original CI artifacts retain the independently verifiable package +attestations. +R2 credentials use `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY`; the publisher +signs every S3 request with SigV4 and includes conditional headers in that signature. +All network requests use HTTPS, fixed timeouts and no implicit redirects. Errors +do not print subprocess output, signing-key paths, request headers or credentials. + +Publication finishes by checking the live latest and immutable signed manifest, +signature, full artifact hashes, HEAD lengths, one-byte ranges, and installer +redirect destinations. Repeat that separately with `verify-live --bundle bundle +--public-key bundle/public-key.pub --base-url https://updates.trymaple.ai` after a +transport failure or operational change. If this final check fails after promotion, +inspect the live state and retry the identical bundle; publication may already +have completed. This check does not install or relaunch an application. + +Run offline tests: + +```sh +nix develop --no-update-lock-file .#ci -c python3 -m unittest discover \ + -s services/agent-updates/publisher -p 'test_*.py' +``` + +The fake-store tests cover interrupted writes, reservation/envelope reuse, +immutable conflicts, pointer races and already-published recovery. Their synthetic +signatures exercise envelope plumbing without making a cryptographic claim. +The pinned-shell minisign tests verify the shared public wire fixture, generate +disposable offline keys only inside a private temporary directory, sign a reserved +manifest, and exercise verification, tampering and deterministic signing retries. +No production credentials, resources or live release are used by these tests. diff --git a/services/agent-updates/publisher/fixtures/README.md b/services/agent-updates/publisher/fixtures/README.md new file mode 100644 index 000000000..8995dbb52 --- /dev/null +++ b/services/agent-updates/publisher/fixtures/README.md @@ -0,0 +1,8 @@ +These are public test fixtures, signed with the deterministic test-only seed +`[7; 32]` also used by `src/tests.rs`. This key is public knowledge and must never +be pinned in an application profile or used to sign a production release. + +`manifest.json` contains the exact bytes authenticated by `manifest.minisig`. +`signed-envelope.json` embeds the same bytes and signature for client, Worker +and publisher contract tests. The artifact bytes are the ASCII string +`signed payload fixture bytes`; they are not an application archive. diff --git a/services/agent-updates/publisher/fixtures/manifest.json b/services/agent-updates/publisher/fixtures/manifest.json new file mode 100644 index 000000000..62fc084c9 --- /dev/null +++ b/services/agent-updates/publisher/fixtures/manifest.json @@ -0,0 +1 @@ +{"format":"maple-agent-update-v1","product":"maple-agent","channel":"stable","version":"0.0.2","build":"2","source_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","pub_date":"2026-10-21T15:00:00Z","notes":"A test update.\nPlain text.","artifacts":{"darwin-aarch64":{"path":"/agent/stable/releases/0.0.2/Maple-Agent.app.tar.gz","size":28,"sha256":"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da"}},"downloads":{"macos":{"path":"/agent/stable/releases/0.0.2/Maple-Agent.dmg","size":28,"sha256":"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da"}}} \ No newline at end of file diff --git a/services/agent-updates/publisher/fixtures/manifest.minisig b/services/agent-updates/publisher/fixtures/manifest.minisig new file mode 100644 index 000000000..a2ef4d8c8 --- /dev/null +++ b/services/agent-updates/publisher/fixtures/manifest.minisig @@ -0,0 +1,4 @@ +untrusted comment: TEST ONLY +RUQxMjM0NTY3OCUVO3EkRz9emd4Vq7qd4IR+x9DelvoFJkcVwb/q0ODwQHQxK7yxsqUtoOA++iMkjIiVg3y+EKA964mP5WQwdw0= +trusted comment: product=maple-agent channel=stable version=0.0.2 +4Mo/xlTkXa0LMc4N+SJyNbTqErz0CLZg4vQMSCz4KzR1MdAITbAmXq9Vy4aXRhSXtFoeuTBSe3w4IF8I+aMXDQ== diff --git a/services/agent-updates/publisher/fixtures/public-test-key.pub b/services/agent-updates/publisher/fixtures/public-test-key.pub new file mode 100644 index 000000000..9d4de7dda --- /dev/null +++ b/services/agent-updates/publisher/fixtures/public-test-key.pub @@ -0,0 +1,2 @@ +untrusted comment: PUBLIC TEST KEY; NEVER SHIP +RWQxMjM0NTY3OOpKbGPinFIKvvVQexMuxfmVR3auvr57kkIe6mkURtIs diff --git a/services/agent-updates/publisher/fixtures/signed-envelope.json b/services/agent-updates/publisher/fixtures/signed-envelope.json new file mode 100644 index 000000000..385f99a0f --- /dev/null +++ b/services/agent-updates/publisher/fixtures/signed-envelope.json @@ -0,0 +1,4 @@ +{ + "manifest": "{\"format\":\"maple-agent-update-v1\",\"product\":\"maple-agent\",\"channel\":\"stable\",\"version\":\"0.0.2\",\"build\":\"2\",\"source_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"pub_date\":\"2026-10-21T15:00:00Z\",\"notes\":\"A test update.\\nPlain text.\",\"artifacts\":{\"darwin-aarch64\":{\"path\":\"/agent/stable/releases/0.0.2/Maple-Agent.app.tar.gz\",\"size\":28,\"sha256\":\"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da\"}},\"downloads\":{\"macos\":{\"path\":\"/agent/stable/releases/0.0.2/Maple-Agent.dmg\",\"size\":28,\"sha256\":\"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da\"}}}", + "signature": "untrusted comment: TEST ONLY\nRUQxMjM0NTY3OCUVO3EkRz9emd4Vq7qd4IR+x9DelvoFJkcVwb/q0ODwQHQxK7yxsqUtoOA++iMkjIiVg3y+EKA964mP5WQwdw0=\ntrusted comment: product=maple-agent channel=stable version=0.0.2\n4Mo/xlTkXa0LMc4N+SJyNbTqErz0CLZg4vQMSCz4KzR1MdAITbAmXq9Vy4aXRhSXtFoeuTBSe3w4IF8I+aMXDQ==\n" +} diff --git a/services/agent-updates/publisher/publisher.py b/services/agent-updates/publisher/publisher.py new file mode 100644 index 000000000..f9423dfc1 --- /dev/null +++ b/services/agent-updates/publisher/publisher.py @@ -0,0 +1,855 @@ +#!/usr/bin/env python3 +"""Prepare, sign and conditionally publish independently delivered Agent updates. + +Only Python's standard library is used. GitHub provenance is checked with gh; +cryptography uses the repository's pinned minisign, never downloaded executables. +""" + +from __future__ import annotations + +import argparse +import base64 +from contextlib import contextmanager +from datetime import datetime, timezone +import hashlib +import hmac +import importlib.util +import json +import os +from pathlib import Path +import plistlib +import re +import shutil +import stat +import subprocess +import sys +import tempfile +import tarfile +import urllib.error +import urllib.parse +import urllib.request +from xml.parsers.expat import ExpatError + + +REPOSITORY = "MaplePrivacyLabs/Maple" +WORKFLOW = ".github/workflows/agent-desktop-build.yml" +MAX_ARTIFACT = 4 * 1024**3 +MAX_MANIFEST = 64 * 1024 +MAX_ENVELOPE = 128 * 1024 +MAX_SIGNATURE = 4 * 1024 +MAX_NOTES = 16 * 1024 +MAX_JSON = 4 * 1024 * 1024 +CHUNK = 1024 * 1024 +NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*\Z") +SEMVER = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") +SHA = re.compile(r"[0-9a-f]{40}\Z") +DIGEST = re.compile(r"[0-9a-f]{64}\Z") +ROOT = Path(__file__).resolve().parents[3] +FEED_URL = "https://updates.trymaple.ai/agent/stable/latest.json" +MACOS_METADATA_NAMES = {"macos-" + name for name in ("build-info.json", "manifest.json", "SHA256SUMS", "notarization-log.json", "dmg-notarization-log.json")} +LINUX_METADATA_NAMES = {"linux-" + name for name in ("build-info.json", "manifest.json", "SHA256SUMS")} +METADATA_NAMES = {"provenance.json"} | MACOS_METADATA_NAMES | LINUX_METADATA_NAMES + + +class PublishError(ValueError): + """A bounded, credential-free diagnostic safe to print in CI.""" + + +class StoreError(PublishError): + def __init__(self, status): + super().__init__(f"R2 request failed (HTTP {status})") + self.status = status + + +def need(condition, message): + if not condition: + raise PublishError(message) + + +def unique_object(pairs): + result = {} + for key, value in pairs: + need(key not in result, "duplicate JSON field") + result[key] = value + return result + + +def decode_json(data, maximum=MAX_JSON): + need(len(data) <= maximum, "oversized JSON") + try: + return json.loads(data.decode("utf-8"), object_pairs_hook=unique_object, + parse_constant=lambda _: (_ for _ in ()).throw(PublishError("non-finite JSON number"))) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError) as error: + raise PublishError("invalid JSON") from error + + +def encode_json(value): + return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8") + + +@contextmanager +def regular_file(path): + # NOFOLLOW and NONBLOCK reject symlinks/FIFOs before a privileged job reads. + descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) + with os.fdopen(descriptor, "rb") as source: + need(stat.S_ISREG(os.fstat(source.fileno()).st_mode), "expected a regular file") + yield source + + +def read_bytes(path, maximum): + with regular_file(path) as source: + need(os.fstat(source.fileno()).st_size <= maximum, "oversized input file") + data = source.read(maximum + 1) + need(len(data) <= maximum, "oversized input file") + return data + + +def hash_stream(source, maximum=MAX_ARTIFACT): + digest, size = hashlib.sha256(), 0 + while block := source.read(min(CHUNK, maximum - size + 1)): + size += len(block) + need(size <= maximum, "oversized artifact") + digest.update(block) + return digest.hexdigest(), size + + +def hash_file(path, maximum=MAX_ARTIFACT): + with regular_file(path) as source: + need(0 < os.fstat(source.fileno()).st_size <= maximum, "invalid artifact size") + return hash_stream(source, maximum) + + +def write_once(path, data): + path = Path(path) + if path.exists() or path.is_symlink(): + need(read_bytes(path, max(MAX_JSON, len(data))) == data, "existing prepared bytes differ; use the original bundle") + return + with path.open("xb") as target: + target.write(data) + + +def version_tuple(value): + need(isinstance(value, str) and len(value) <= 64 and SEMVER.fullmatch(value), "version must be canonical stable SemVer") + numbers = tuple(int(component) for component in value.split(".")) + need(all(number <= 2**64 - 1 for number in numbers), "SemVer component exceeds u64") + return numbers + + +def trusted_comment(version): + return f"product=maple-agent channel=stable version={version}" + + +def release_key(version, filename): + version_tuple(version) + need(isinstance(filename, str) and len(filename) <= 200 and ".." not in filename and NAME.fullmatch(filename), "invalid release filename") + return f"stable/releases/{version}/{filename}" + + +def public_path(version, filename): + return "/agent/" + release_key(version, filename) + + +def validate_manifest(data): + manifest = decode_json(data, MAX_MANIFEST) + fields = {"format", "product", "channel", "version", "build", "source_sha", "pub_date", "notes", "artifacts", "downloads"} + need(isinstance(manifest, dict) and set(manifest) == fields, "invalid manifest fields") + need(manifest["format"] == "maple-agent-update-v1" and manifest["product"] == "maple-agent" and manifest["channel"] == "stable", "wrong update identity") + version_tuple(manifest["version"]) + build = manifest["build"] + need(isinstance(build, str) and re.fullmatch(r"[1-9][0-9]{0,17}", build), "invalid build number") + need(isinstance(manifest["source_sha"], str) and SHA.fullmatch(manifest["source_sha"]), "invalid source SHA") + date = manifest["pub_date"] + need(isinstance(date, str) and re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z", date), "invalid publication date") + try: + datetime.strptime(date, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as error: + raise PublishError("invalid publication date") from error + notes = manifest["notes"] + need(isinstance(notes, str) and len(notes.encode("utf-8")) <= MAX_NOTES, "oversized notes") + need(all(ord(char) >= 32 or char in "\n\t" for char in notes) and "\x7f" not in notes, "notes contain ASCII controls") + artifacts, downloads = manifest["artifacts"], manifest["downloads"] + need(isinstance(artifacts, dict) and {"darwin-aarch64"} <= set(artifacts) <= {"darwin-aarch64", "linux-x86_64"}, "invalid update targets") + need(isinstance(downloads, dict) and set(downloads) == ({"macos", "linux-appimage"} if "linux-x86_64" in artifacts else {"macos"}), "downloads do not match update targets") + for kind, entries in (("artifacts", artifacts), ("downloads", downloads)): + for target, entry in entries.items(): + need(isinstance(entry, dict) and set(entry) == {"path", "size", "sha256"}, "invalid artifact record") + path = entry["path"] + need(isinstance(path, str), "invalid artifact path") + prefix = f"/agent/stable/releases/{manifest['version']}/" + need(path.startswith(prefix), "artifact path has wrong release root") + filename = path[len(prefix):] + need(public_path(manifest["version"], filename) == path, "invalid artifact path") + suffix = ".app.tar.gz" if target == "darwin-aarch64" else ".dmg" if target == "macos" else ".AppImage" + need(filename.endswith(suffix), "wrong artifact suffix") + need(type(entry["size"]) is int and 0 < entry["size"] <= MAX_ARTIFACT, "invalid artifact size") + need(isinstance(entry["sha256"], str) and DIGEST.fullmatch(entry["sha256"]), "invalid artifact digest") + if "linux-x86_64" in artifacts: + need(artifacts["linux-x86_64"] == downloads["linux-appimage"], "Linux download and update differ") + return manifest + + +def signature_text(signature, version): + need(isinstance(signature, str) and len(signature.encode("utf-8")) <= MAX_SIGNATURE, "invalid or oversized signature") + lines = signature.splitlines() + need(len(lines) == 4 and lines[0].startswith("untrusted comment: ") and lines[2] == "trusted comment: " + trusted_comment(version), "wrong minisign comment or signature structure") + try: + packet = base64.b64decode(lines[1], validate=True) + global_signature = base64.b64decode(lines[3], validate=True) + except ValueError as error: + raise PublishError("invalid minisign encoding") from error + need(len(packet) == 74 and packet[:2] == b"ED" and len(global_signature) == 64, "signature must use prehashed minisign") + + +def parse_envelope(data): + envelope = decode_json(data, MAX_ENVELOPE) + need(isinstance(envelope, dict) and set(envelope) == {"manifest", "signature"} and isinstance(envelope["manifest"], str), "invalid signed envelope") + manifest = validate_manifest(envelope["manifest"].encode("utf-8")) + signature_text(envelope["signature"], manifest["version"]) + return envelope, manifest + + +def minisign_verify(data, public_key): + envelope, manifest = parse_envelope(data) + # Never print tool stderr: it may contain untrusted data or a secret path. + with tempfile.TemporaryDirectory(prefix="maple-agent-verify-") as temporary: + message = Path(temporary) / "manifest.json" + signature = Path(temporary) / "signature.minisig" + message.write_bytes(envelope["manifest"].encode("utf-8")) + signature.write_text(envelope["signature"], encoding="utf-8") + result = subprocess.run(["minisign", "-V", "-H", "-q", "-m", str(message), "-x", str(signature), "-p", str(public_key)], stdin=subprocess.DEVNULL, capture_output=True, timeout=30) + need(result.returncode == 0, "minisign verification failed") + return manifest + + +def gh_json(path): + result = subprocess.run(["gh", "api", "--method", "GET", path], capture_output=True, timeout=60) + need(result.returncode == 0, "GitHub API request failed") + return decode_json(result.stdout) + + +def validate_run(run, jobs, linux=False): + need(isinstance(run, dict) and run.get("repository", {}).get("full_name") == REPOSITORY and run.get("head_repository", {}).get("full_name") == REPOSITORY, "untrusted build repository") + need(run.get("path") == WORKFLOW and run.get("head_branch") == "master" and run.get("event") in {"push", "workflow_dispatch"}, "untrusted build workflow or ref") + need(run.get("status") == "completed", "build run has not completed") + need(isinstance(run.get("head_sha"), str) and SHA.fullmatch(run["head_sha"]), "invalid build commit") + need(type(run.get("id")) is int and run["id"] > 0 and type(run.get("run_attempt")) is int and run["run_attempt"] > 0, "invalid build run identity") + need(type(run.get("run_number")) is int and 0 < run["run_number"] < 10**18, "invalid build run number") + need(isinstance(jobs, dict) and jobs.get("total_count") == len(jobs.get("jobs", [])), "incomplete build job inventory") + required = {"Signed macOS (prod)", "Verify downloaded macOS (prod)"} + if linux: + required |= {"Linux AppImage (prod)", "Attest Linux packages (prod)", "Verify downloaded Linux (prod)"} + for name in required: + matches = [job for job in jobs["jobs"] if job.get("name") == name] + need(len(matches) == 1 and matches[0].get("status") == "completed" and matches[0].get("conclusion") == "success", "required Prod package job did not succeed: " + name) + # An unrelated Dev or platform failure is not an Agent Prod release gate. + # Overall run success is intentionally not treated as package proof. + return run + + +def trust_run(run_id, linux=False): + need(str(run_id).isdecimal() and int(run_id) > 0, "invalid run ID") + run = gh_json(f"repos/{REPOSITORY}/actions/runs/{int(run_id)}") + attempt = run.get("run_attempt") + need(type(attempt) is int and attempt > 0, "invalid build attempt") + # GitHub's latest job inventory preserves successful producer jobs when + # only failed consumers were re-run. An attempt-only inventory would omit + # those producers even though their exact artifacts remain authoritative. + jobs = gh_json(f"repos/{REPOSITORY}/actions/runs/{int(run_id)}/jobs?filter=latest&per_page=100") + validate_run(run, jobs, linux) + need(run["id"] == int(run_id), "build run ID differs") + artifacts = gh_json(f"repos/{REPOSITORY}/actions/runs/{int(run_id)}/artifacts?per_page=100") + need(artifacts.get("total_count") == len(artifacts.get("artifacts", [])), "incomplete artifact inventory") + names = {"macos": f"maple-agent-prod-macos-aarch64-{int(run_id)}"} + if linux: + names["linux"] = f"maple-agent-prod-linux-x86_64-{int(run_id)}" + for name in names.values(): + matches = [item for item in artifacts["artifacts"] if item.get("name") == name] + need(len(matches) == 1 and matches[0].get("expired") is False, "required package artifact missing, duplicated or expired") + return {"run_id": run["id"], "run_attempt": attempt, "source_sha": run["head_sha"], "build": str(run["run_number"]), "pub_date": run["created_at"], "artifacts": names} + + +def attest(path, source_sha): + result = subprocess.run(["gh", "attestation", "verify", str(path), "--repo", REPOSITORY, "--signer-workflow", f"{REPOSITORY}/{WORKFLOW}", "--source-ref", "refs/heads/master", "--source-digest", source_sha, "--deny-self-hosted-runners"], stdin=subprocess.DEVNULL, capture_output=True, timeout=120) + need(result.returncode == 0, "final package attestation verification failed") + + +def public_key_text(path): + lines = read_bytes(path, MAX_SIGNATURE).decode("ascii").splitlines() + if len(lines) == 2 and lines[0].startswith("untrusted comment: "): + lines = lines[1:] + need(len(lines) == 1, "invalid minisign public key file") + try: + packet = base64.b64decode(lines[0], validate=True) + except ValueError as error: + raise PublishError("invalid minisign public key encoding") from error + need(len(packet) == 42 and packet[:2] == b"Ed", "invalid minisign public key") + return lines[0] + + +def validate_pins(key, local_profile, source_profile): + for profile in (local_profile, source_profile): + need(isinstance(profile, dict) and isinstance(profile.get("prod"), dict), "missing Prod release profile") + prod = profile["prod"] + need(prod.get("update_feed_url") == FEED_URL, "Prod update feed does not match the production feed") + keys = prod.get("update_public_keys") + need(isinstance(keys, list) and all(isinstance(item, str) for item in keys) and key in keys, "signing key is not pinned by the selected source and trusted publisher") + + +def check_source_pins(public_key, source_sha): + key = public_key_text(public_key) + local = decode_json(read_bytes(ROOT / "apps/maple-agent/release-profiles.json", MAX_MANIFEST)) + response = gh_json(f"repos/{REPOSITORY}/contents/apps/maple-agent/release-profiles.json?ref={source_sha}") + need(isinstance(response, dict) and response.get("encoding") == "base64" and isinstance(response.get("content"), str), "selected source profile is unavailable") + try: + source_bytes = base64.b64decode(response["content"].replace("\n", ""), validate=True) + except ValueError as error: + raise PublishError("selected source profile encoding is invalid") from error + validate_pins(key, local, decode_json(source_bytes, MAX_MANIFEST)) + return key + + +def durable_provenance(proof): + # A verifier-only rerun advances the diagnostic attempt while retaining + # the exact source, packages, hashes and release identity. + provenance = {name: value for name, value in proof.items() if name != "run_attempt"} + provenance["attestation_policy"] = {"repository": REPOSITORY, "signer_workflow": WORKFLOW, "source_ref": "refs/heads/master", "source_sha": proof["source_sha"], "deny_self_hosted_runners": True} + return encode_json(provenance) + + +def verify_current(data, public_key): + """Accept an existing feed signed by either currently trusted pinned key. + + The new release still has to use the specifically selected key. This + fallback only verifies the pointer being replaced during normal rotation. + Removing the old key deliberately stops accepting its feed as authority. + """ + selected = public_key_text(public_key) + profile = decode_json(read_bytes(ROOT / "apps/maple-agent/release-profiles.json", MAX_MANIFEST)) + validate_pins(selected, profile, profile) + for key in profile["prod"]["update_public_keys"]: + with tempfile.TemporaryDirectory(prefix="maple-agent-current-key-") as temporary: + path = Path(temporary) / "public-key.pub" + path.write_text(key + "\n", encoding="ascii") + public_key_text(path) + try: + return minisign_verify(data, path) + except PublishError: + continue + raise PublishError("current feed signature is not trusted by the pinned key set") + + +class BoundedTarInfo(tarfile.TarInfo): + def _proc_member(self, archive): + # tarfile processes PAX/GNU extension records before yielding a member. + # Cap them here, before its parser can allocate a header-sized buffer. + archive.maple_headers = getattr(archive, "maple_headers", 0) + 1 + need(archive.maple_headers <= 100_000, "app archive exceeds bounded header inventory") + need(0 <= self.size <= MAX_ARTIFACT, "app archive contains an oversized member") + if self.type in {tarfile.XHDTYPE, tarfile.XGLTYPE, tarfile.SOLARIS_XHDTYPE, tarfile.GNUTYPE_LONGNAME, tarfile.GNUTYPE_LONGLINK}: + need(self.size <= 64 * 1024, "app archive extension metadata exceeds bounds") + archive.maple_metadata_bytes = getattr(archive, "maple_metadata_bytes", 0) + self.size + need(archive.maple_metadata_bytes <= MAX_JSON, "app archive cumulative extension metadata exceeds bounds") + need(self.type != tarfile.GNUTYPE_SPARSE, "sparse app archive members are unsupported") + return super()._proc_member(archive) + + def _proc_gnusparse_00(self, *arguments): + raise PublishError("sparse app archive members are unsupported") + + def _proc_gnusparse_01(self, *arguments): + raise PublishError("sparse app archive members are unsupported") + + def _proc_gnusparse_10(self, *arguments): + raise PublishError("sparse app archive members are unsupported") + + +def _archive_plist(archive): + expected = "Maple Agent.app/Contents/Info.plist" + found, count, expanded = None, 0, 0 + with regular_file(archive) as compressed: + with tarfile.open(fileobj=compressed, mode="r|gz", tarinfo=BoundedTarInfo) as source: + for member in source: + count += 1 + expanded += member.size + need(count <= 100_000 and 0 <= member.size <= MAX_ARTIFACT and expanded <= 8 * 1024**3, "app archive exceeds bounded inventory") + if member.name != expected: + continue + need(found is None and member.isfile() and 0 < member.size <= 64 * 1024, "invalid or duplicate app Info.plist member") + with source.extractfile(member) as payload: + data = payload.read(64 * 1024 + 1) + need(len(data) == member.size, "truncated app Info.plist") + try: + found = plistlib.loads(data) + except (plistlib.InvalidFileException, ValueError, TypeError, ExpatError, RecursionError) as error: + raise PublishError("invalid app Info.plist") from error + return found + + +def validate_archive_build(archive, version, build, bundle_id="cloud.opensecret.maple.agent"): + """Read the small plist member without extracting or executing the app.""" + try: + found = _archive_plist(archive) + except (tarfile.TarError, EOFError, OSError, RecursionError) as error: + raise PublishError("invalid app archive") from error + need(isinstance(found, dict), "app archive has no regular Info.plist") + need(found.get("CFBundleVersion") == build and found.get("CFBundleShortVersionString") == version and found.get("CFBundleIdentifier") == bundle_id, "app Info.plist build, version or identity differs from selected run") + + +def validate_package(directory, platform, source_sha, version, attestor=attest, expected_build=None, metadata_records=None): + directory = Path(directory) + need(directory.is_dir() and not directory.is_symlink(), "package input must be a directory") + info = decode_json(read_bytes(directory / "build-info.json", 16 * 1024)) + # Reuse the checked-in public profile contract, never package-supplied code. + script = ROOT / "apps/maple-agent/scripts/release-info.py" + spec = importlib.util.spec_from_file_location("agent_release_info", script) + release_info = importlib.util.module_from_spec(spec) + spec.loader.exec_module(release_info) + try: + release_info.validate_info("prod", info, source_sha) + except (ValueError, KeyError, TypeError) as error: + raise PublishError("package does not match the Prod build identity") from error + need(info["version"] == version, "package version differs from publication version") + stem = f"maple-agent-prod-{version}-{info['git_revision']}-{platform}" + package = stem + (".dmg" if platform == "macos-aarch64" else ".AppImage") + binaries = [package, stem + ".app.tar.gz"] if platform == "macos-aarch64" else [package] + expected = {"build-info.json", "manifest.json", "SHA256SUMS", *binaries} + if platform == "macos-aarch64": + expected |= {"notarization-log.json", "dmg-notarization-log.json"} + need(set(path.name for path in directory.iterdir()) == expected, "unexpected or missing final package files") + records = {} + for name in sorted(expected): + path = directory / name + digest, size = hash_file(path, MAX_ARTIFACT if name in binaries else MAX_JSON) + records[name] = {"sha256": digest, "size": size} + metadata = decode_json(read_bytes(directory / "manifest.json", MAX_MANIFEST)) + expected_metadata = {"schema_version": 1, "profile": "prod", "platform": platform, "source_sha": source_sha, "version": version, "package": package, "distribution": "master", "macos_signing": "developer-id-notarized" if platform == "macos-aarch64" else None} + if platform == "linux-x86_64": + expected_metadata["linux_integrity"] = "sha256" + need(isinstance(metadata, dict) and type(metadata.get("schema_version")) is int and metadata == expected_metadata, "invalid final package metadata") + checked = set() + for line in read_bytes(directory / "SHA256SUMS", 16 * 1024).decode("ascii").splitlines(): + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9._-]+)", line) + need(match is not None, "invalid package checksum record") + digest, name = match.groups() + need(name not in checked and name != "SHA256SUMS" and name in records and records[name]["sha256"] == digest, "package checksum mismatch or duplicate") + checked.add(name) + need(checked == expected - {"SHA256SUMS"}, "package checksum inventory differs") + if platform == "macos-aarch64": + for name in ("notarization-log.json", "dmg-notarization-log.json"): + notary = decode_json(read_bytes(directory / name, MAX_JSON)) + need(isinstance(notary, dict) and notary.get("status") == "Accepted" and notary.get("issues") in (None, []), "package notarization was not accepted") + if expected_build is not None: + validate_archive_build(directory / binaries[1], version, expected_build, info["bundle_id"]) + for name in sorted(expected): + attestor(directory / name, source_sha) + if metadata_records is not None: + metadata_records.update({name: record for name, record in records.items() if name not in binaries}) + return [(directory / name, {"path": public_path(version, name), **records[name]}) for name in binaries] + + +def prepare(run_id, version, build, macos, linux, notes_file, output, public_key, pub_date=None): + version_tuple(version) + proof = trust_run(run_id, bool(linux)) + need(build == proof["build"], "build number must match the package run number") + key = check_source_pins(public_key, proof["source_sha"]) + notes = read_bytes(notes_file, MAX_NOTES).decode("utf-8") + mac_metadata, linux_metadata = {}, {} + mac = validate_package(macos, "macos-aarch64", proof["source_sha"], version, expected_build=proof["build"], metadata_records=mac_metadata) + lin = validate_package(linux, "linux-x86_64", proof["source_sha"], version, metadata_records=linux_metadata) if linux else [] + manifest = {"format": "maple-agent-update-v1", "product": "maple-agent", "channel": "stable", "version": version, "build": build, "source_sha": proof["source_sha"], "pub_date": pub_date or proof["pub_date"], "notes": notes, "artifacts": {"darwin-aarch64": mac[1][1]}, "downloads": {"macos": mac[0][1]}} + if lin: + manifest["artifacts"]["linux-x86_64"] = lin[0][1] + manifest["downloads"]["linux-appimage"] = lin[0][1] + data = encode_json(manifest) + validate_manifest(data) + output = Path(output) + output.mkdir(parents=True, exist_ok=True) + need(not output.is_symlink(), "bundle directory must not be a symlink") + write_once(output / "manifest.json", data) + write_once(output / "trust.json", encode_json(proof)) + write_once(output / "public-key.pub", ("untrusted comment: Maple Agent update key\n" + key + "\n").encode("ascii")) + metadata = output / "metadata" + metadata.mkdir(exist_ok=True) + need(not metadata.is_symlink(), "metadata directory must not be a symlink") + inventory = {} + for prefix, directory, source_records in (("macos", macos, mac_metadata), ("linux", linux, linux_metadata)): + for name, record in sorted(source_records.items()): + data = read_bytes(Path(directory) / name, MAX_JSON) + need((hashlib.sha256(data).hexdigest(), len(data)) == (record["sha256"], record["size"]), "package metadata changed after provenance verification") + destination = prefix + "-" + name + write_once(metadata / destination, data) + inventory[destination] = record + # A validator-only build retry advances run_attempt without changing any + # release bytes. Retain the stable build identity in durable audit data; + # the current attempt remains available in the CI trust.json diagnostic. + provenance_data = durable_provenance(proof) + write_once(metadata / "provenance.json", provenance_data) + inventory["provenance.json"] = {"sha256": hashlib.sha256(provenance_data).hexdigest(), "size": len(provenance_data)} + write_once(output / "metadata-inventory.json", encode_json(inventory)) + payloads = output / "payloads" + payloads.mkdir(exist_ok=True) + need(not payloads.is_symlink(), "payload directory must not be a symlink") + for source, record in mac + lin: + destination = payloads / source.name + if destination.exists() or destination.is_symlink(): + need(hash_file(destination) == (record["sha256"], record["size"]), "existing prepared artifact differs") + else: + with regular_file(source) as input_file, destination.open("xb") as target: + shutil.copyfileobj(input_file, target, CHUNK) + need(hash_file(destination) == (record["sha256"], record["size"]), "artifact changed during preparation") + return manifest + + +def bundle_records(bundle, manifest): + payloads = Path(bundle) / "payloads" + need(payloads.is_dir() and not payloads.is_symlink(), "invalid prepared payload directory") + records = {record["path"].rsplit("/", 1)[1]: record for record in [*manifest["artifacts"].values(), *manifest["downloads"].values()]} + need(set(path.name for path in payloads.iterdir()) == set(records), "prepared payload inventory differs") + for name, record in records.items(): + need(hash_file(payloads / name, record["size"]) == (record["sha256"], record["size"]), "prepared artifact hash differs") + return [(payloads / name, record) for name, record in sorted(records.items())] + + +def bundle_metadata(bundle, manifest): + bundle = Path(bundle) + index = bundle / "metadata-inventory.json" + need(index.exists() or index.is_symlink(), "metadata has no inventory") + data = read_bytes(index, MAX_MANIFEST) + inventory = decode_json(data, MAX_MANIFEST) + expected = {"provenance.json"} | MACOS_METADATA_NAMES + if "linux-x86_64" in manifest["artifacts"]: + expected |= LINUX_METADATA_NAMES + need(isinstance(inventory, dict) and set(inventory) == expected, "private metadata inventory must contain the exact sidecars for every included target") + directory = bundle / "metadata" + need(directory.is_dir() and not directory.is_symlink() and {path.name for path in directory.iterdir()} == set(inventory), "private metadata inventory differs") + result = [] + for name, record in sorted(inventory.items()): + need(isinstance(record, dict) and set(record) == {"sha256", "size"} and isinstance(record["sha256"], str) and DIGEST.fullmatch(record["sha256"]), "invalid private metadata record") + need(type(record["size"]) is int and 0 < record["size"] <= MAX_JSON, "invalid private metadata size") + path = directory / name + need(hash_file(path, record["size"]) == (record["sha256"], record["size"]), "private metadata hash differs") + result.append((path, record)) + return data, result + + +def metadata_key(version, filename): + need(filename in METADATA_NAMES, "invalid private metadata name") + return release_key(version, "metadata") + "/" + filename + + +def publish_metadata(store, version, records): + for path, record in records: + key = metadata_key(version, path.name) + immutable_put(store, key, path, "application/json" if path.suffix == ".json" else "text/plain", maximum=record["size"]) + need(store.same(key, record["sha256"], record["size"]), "private metadata read-back differs") + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, file, code, message, headers, new_url): + return None + + +def https_origin(value): + parsed = urllib.parse.urlsplit(value) + need(parsed.scheme == "https" and parsed.hostname and not parsed.username and not parsed.password and not parsed.query and not parsed.fragment and parsed.path in {"", "/"} and parsed.port in {None, 443}, "expected a plain HTTPS origin") + return f"https://{parsed.netloc}" + + +class R2Store: + """Path-style S3 requests with signed conditional headers; no redirects.""" + + def __init__(self, endpoint, bucket, access_key=None, secret_key=None): + self.endpoint = https_origin(endpoint) + need(re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", bucket), "invalid bucket name") + self.bucket = bucket + self.access_key = access_key or os.environ.get("AWS_ACCESS_KEY_ID", "") + self.secret_key = secret_key or os.environ.get("AWS_SECRET_ACCESS_KEY", "") + need(self.access_key and self.secret_key, "R2 credentials are not configured") + self.opener = urllib.request.build_opener(NoRedirect) + + def request(self, method, key, data=None, content_hash=None, size=None, condition=None, content_type=None, maximum=MAX_JSON, consumer=None): + need(not key.startswith("/") and all(NAME.fullmatch(part) for part in key.split("/")), "invalid R2 object key") + path = "/" + self.bucket + "/" + urllib.parse.quote(key, safe="/-_.~") + now = datetime.now(timezone.utc) + timestamp, day = now.strftime("%Y%m%dT%H%M%SZ"), now.strftime("%Y%m%d") + digest = content_hash or hashlib.sha256(data or b"").hexdigest() + headers = {"host": urllib.parse.urlsplit(self.endpoint).netloc, "x-amz-content-sha256": digest, "x-amz-date": timestamp} + if condition: + headers[condition[0].lower()] = condition[1] + if content_type: + headers["content-type"] = content_type + if size is not None: + headers["content-length"] = str(size) + names = ";".join(sorted(headers)) + canonical_headers = "".join(f"{name}:{headers[name].strip()}\n" for name in sorted(headers)) + canonical = f"{method}\n{path}\n\n{canonical_headers}\n{names}\n{digest}" + scope = f"{day}/auto/s3/aws4_request" + to_sign = f"AWS4-HMAC-SHA256\n{timestamp}\n{scope}\n{hashlib.sha256(canonical.encode()).hexdigest()}" + signing = ("AWS4" + self.secret_key).encode() + for part in (day, "auto", "s3", "aws4_request"): + signing = hmac.new(signing, part.encode(), hashlib.sha256).digest() + signature = hmac.new(signing, to_sign.encode(), hashlib.sha256).hexdigest() + headers["authorization"] = f"AWS4-HMAC-SHA256 Credential={self.access_key}/{scope}, SignedHeaders={names}, Signature={signature}" + request = urllib.request.Request(self.endpoint + path, method=method, data=data, headers=headers) + try: + with self.opener.open(request, timeout=120) as response: + response_headers = dict(response.headers.items()) + if consumer is not None: + return consumer(response), response_headers + if method == "HEAD": + return b"", response_headers + body = response.read(maximum + 1) + need(len(body) <= maximum, "oversized R2 response") + return body, response_headers + except urllib.error.HTTPError as error: + raise StoreError(error.code) from None + except (OSError, urllib.error.URLError) as error: + raise PublishError("R2 transport failed") from None + + def get(self, key, maximum=MAX_JSON): + try: + data, headers = self.request("GET", key, maximum=maximum) + except StoreError as error: + if error.status == 404: + return None + raise + etag = next((value for name, value in headers.items() if name.lower() == "etag"), None) + need(etag is not None and len(etag) < 256, "R2 object has no valid ETag") + return data, etag + + def same(self, key, digest, size): + # Read existing content, rather than trusting mutable custom metadata + # or multipart ETags as SHA-256. This is only needed on retries/races. + try: + return self.stream_hash(key, maximum=size) == (digest, size) + except StoreError as error: + if error.status == 404: + return False + raise + + def stream_hash(self, key, maximum=MAX_ARTIFACT): + # A GET can be large: hash a bounded stream, rather than buffering it. + return self.request("GET", key, consumer=lambda response: hash_stream(response, maximum))[0] + + def put(self, key, data, *, condition, content_type="application/octet-stream"): + if isinstance(data, Path): + digest, size = hash_file(data, MAX_JSON if "/metadata/" in key else MAX_ARTIFACT) + with regular_file(data) as source: + self.request("PUT", key, source, digest, size, condition, content_type) + else: + self.request("PUT", key, data, hashlib.sha256(data).hexdigest(), len(data), condition, content_type) + + +def immutable_put(store, key, data, content_type="application/octet-stream", maximum=MAX_ARTIFACT): + if isinstance(data, Path): + digest, size = hash_file(data, maximum) + else: + digest, size = hashlib.sha256(data).hexdigest(), len(data) + try: + store.put(key, data, condition=("If-None-Match", "*"), content_type=content_type) + except StoreError as error: + if error.status != 412: + raise + need(store.same(key, digest, size), "immutable object differs; choose a new release version") + + +def reserve(bundle, store, public_key=None): + bundle = Path(bundle) + data = read_bytes(bundle / "manifest.json", MAX_MANIFEST) + manifest = validate_manifest(data) + bundle_records(bundle, manifest) + metadata_inventory, _ = bundle_metadata(bundle, manifest) + # Reserve exact unsigned bytes before any signing job can use the version. + immutable_put(store, release_key(manifest["version"], "publication-manifest.json"), data, "application/json") + reserved = store.get(release_key(manifest["version"], "publication-manifest.json"), MAX_MANIFEST) + need(reserved is not None and reserved[0] == data, "reserved manifest read-back differs") + immutable_put(store, release_key(manifest["version"], "publication-metadata.json"), metadata_inventory, "application/json") + reserved_metadata = store.get(release_key(manifest["version"], "publication-metadata.json"), MAX_MANIFEST) + need(reserved_metadata is not None and reserved_metadata[0] == metadata_inventory, "reserved metadata read-back differs") + existing = store.get(release_key(manifest["version"], "manifest.json"), MAX_ENVELOPE) + if existing: + envelope, old_manifest = parse_envelope(existing[0]) + need(envelope["manifest"].encode("utf-8") == data, "reserved envelope has different manifest bytes") + if public_key: + minisign_verify(existing[0], public_key) + write_once(bundle / "envelope.json", existing[0]) + receipt = {"manifest_sha256": hashlib.sha256(data).hexdigest(), "metadata_inventory_sha256": hashlib.sha256(metadata_inventory).hexdigest(), "version": manifest["version"]} + write_once(bundle / "reservation.json", encode_json(receipt)) + return manifest + + +def sign(bundle, secret_key, public_key): + bundle = Path(bundle) + need(public_key_text(public_key) == public_key_text(bundle / "public-key.pub"), "signing key differs from the prepared pinned key") + data = read_bytes(bundle / "manifest.json", MAX_MANIFEST) + manifest = validate_manifest(data) + bundle_records(bundle, manifest) + metadata_inventory, _ = bundle_metadata(bundle, manifest) + receipt = decode_json(read_bytes(bundle / "reservation.json", MAX_MANIFEST)) + need(receipt == {"manifest_sha256": hashlib.sha256(data).hexdigest(), "metadata_inventory_sha256": hashlib.sha256(metadata_inventory).hexdigest(), "version": manifest["version"]}, "manifest has not been reserved before signing") + if (bundle / "envelope.json").exists(): + existing = read_bytes(bundle / "envelope.json", MAX_ENVELOPE) + need(parse_envelope(existing)[0]["manifest"].encode("utf-8") == data, "existing envelope differs") + minisign_verify(existing, public_key) + return manifest + with tempfile.TemporaryDirectory(prefix="maple-agent-sign-") as temporary: + message = Path(temporary) / "manifest.json" + signature = Path(temporary) / "signature.minisig" + message.write_bytes(data) + result = subprocess.run(["minisign", "-S", "-W", "-s", str(secret_key), "-m", str(message), "-x", str(signature), "-c", "Maple Agent update", "-t", trusted_comment(manifest["version"])], stdin=subprocess.DEVNULL, capture_output=True, timeout=30) + need(result.returncode == 0, "minisign signing failed") + signature_data = read_bytes(signature, MAX_SIGNATURE).decode("utf-8") + envelope = encode_json({"manifest": data.decode("utf-8"), "signature": signature_data}) + minisign_verify(envelope, public_key) + write_once(bundle / "envelope.json", envelope) + return manifest + + +def publish(bundle, store, public_key, verifier=minisign_verify, previous_verifier=verify_current): + bundle = Path(bundle) + need(public_key_text(public_key) == public_key_text(bundle / "public-key.pub"), "publication key differs from the prepared pinned key") + data = read_bytes(bundle / "manifest.json", MAX_MANIFEST) + envelope = read_bytes(bundle / "envelope.json", MAX_ENVELOPE) + manifest = verifier(envelope, public_key) + need(parse_envelope(envelope)[0]["manifest"].encode("utf-8") == data, "signed and reserved manifest bytes differ") + records = bundle_records(bundle, manifest) + metadata_inventory, metadata_records = bundle_metadata(bundle, manifest) + reserved = store.get(release_key(manifest["version"], "publication-manifest.json"), MAX_MANIFEST) + need(reserved is not None and reserved[0] == data, "version must be reserved before signing and publication") + reserved_metadata = store.get(release_key(manifest["version"], "publication-metadata.json"), MAX_MANIFEST) + need(reserved_metadata is not None and reserved_metadata[0] == metadata_inventory, "metadata must be reserved before signing and publication") + # Snapshot before uploading. An operator changing the pointer while this + # publisher runs must win over this attempt; retry requires fresh review. + pointer = store.get("stable/latest.json", MAX_ENVELOPE) + if pointer: + previous = previous_verifier(pointer[0], public_key) + if pointer[0] == envelope: + # Repair deleted/missing objects with create-only writes; existing + # different bytes remain a hard conflict, even on a feed retry. + # The already matching pointer never needs to be rewritten. + for path, record in records: + immutable_put(store, record["path"][len("/agent/"):], path, maximum=record["size"]) + need(store.same(record["path"][len("/agent/"):], record["sha256"], record["size"]), "published artifact differs or is missing") + publish_metadata(store, manifest["version"], metadata_records) + immutable_put(store, release_key(manifest["version"], "manifest.json"), envelope, "application/json") + immutable = store.get(release_key(manifest["version"], "manifest.json"), MAX_ENVELOPE) + need(immutable is not None and immutable[0] == envelope, "published envelope differs or is missing") + return "already-published" + need(version_tuple(manifest["version"]) > version_tuple(previous["version"]), "release version must advance the published version") + for path, record in records: + immutable_put(store, record["path"][len("/agent/"):], path, maximum=record["size"]) + need(store.same(record["path"][len("/agent/"):], record["sha256"], record["size"]), "uploaded artifact read-back differs") + publish_metadata(store, manifest["version"], metadata_records) + immutable_put(store, release_key(manifest["version"], "manifest.json"), envelope, "application/json") + complete = store.get(release_key(manifest["version"], "manifest.json"), MAX_ENVELOPE) + need(complete is not None and complete[0] == envelope, "complete manifest read-back differs") + condition = ("If-Match", pointer[1]) if pointer else ("If-None-Match", "*") + try: + store.put("stable/latest.json", envelope, condition=condition, content_type="application/json") + except StoreError as error: + if error.status == 412: + raise PublishError("publication pointer changed concurrently; inspect it before retrying") from None + raise + return "published" + + +def live_request(opener, url, *, method="GET", headers=None, maximum=MAX_ENVELOPE): + request = urllib.request.Request(url, method=method, headers=headers or {}) + try: + response = opener.open(request, timeout=120) + except urllib.error.HTTPError as error: + response = error + except (OSError, urllib.error.URLError): + raise PublishError("live endpoint transport failed") from None + with response: + data = response.read(maximum + 1) + need(len(data) <= maximum, "oversized live response") + return response.status, response.headers, data + + +def verify_live(bundle, public_key, base_url): + bundle = Path(bundle) + origin = https_origin(base_url) + envelope = read_bytes(bundle / "envelope.json", MAX_ENVELOPE) + manifest = minisign_verify(envelope, public_key) + bundle_records(bundle, manifest) + opener = urllib.request.build_opener(NoRedirect) + for path, expected in (("/agent/stable/latest.json", envelope), (public_path(manifest["version"], "manifest.json"), envelope)): + status, _, body = live_request(opener, origin + path) + need(status == 200 and body == expected, "live signed metadata differs") + for record in {item["path"]: item for item in [*manifest["artifacts"].values(), *manifest["downloads"].values()]}.values(): + status, headers, _ = live_request(opener, origin + record["path"], method="HEAD", maximum=0) + need(status == 200 and headers.get("Content-Length") == str(record["size"]), "live artifact HEAD failed") + status, headers, part = live_request(opener, origin + record["path"], headers={"Range": "bytes=0-0"}, maximum=1) + need(status == 206 and len(part) == 1 and headers.get("Content-Range") == f"bytes 0-0/{record['size']}", "live artifact range request failed") + request = urllib.request.Request(origin + record["path"]) + try: + with opener.open(request, timeout=120) as response: + need(response.status == 200 and hash_stream(response, record["size"]) == (record["sha256"], record["size"]), "live artifact digest differs") + except (urllib.error.URLError, OSError): + raise PublishError("live artifact download failed") from None + for target, record in manifest["downloads"].items(): + status, headers, _ = live_request(opener, origin + f"/download/agent/stable/{target}", maximum=1024) + need(status in {302, 307} and headers.get("Location") in {record["path"], origin + record["path"]}, "live download redirect differs") + return manifest + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + trust = commands.add_parser("trust") + trust.add_argument("--run-id", required=True) + trust.add_argument("--linux", action="store_true") + trust.add_argument("--output", required=True) + prep = commands.add_parser("prepare") + prep.add_argument("--run-id", required=True) + prep.add_argument("--version", required=True) + prep.add_argument("--build", required=True) + prep.add_argument("--macos", required=True) + prep.add_argument("--linux") + prep.add_argument("--notes-file", required=True) + prep.add_argument("--pub-date") + prep.add_argument("--output", required=True) + prep.add_argument("--public-key", required=True) + for name in ("reserve", "sign", "publish", "verify-live"): + command = commands.add_parser(name) + command.add_argument("--bundle", required=True) + command.add_argument("--public-key", required=True) + if name in {"reserve", "publish"}: + command.add_argument("--endpoint", required=True) + command.add_argument("--bucket", required=True) + if name == "sign": + command.add_argument("--secret-key", required=True) + if name in {"publish", "verify-live"}: + command.add_argument("--base-url", required=True) + args = parser.parse_args() + if args.command == "trust": + proof = trust_run(args.run_id, args.linux) + write_once(args.output, encode_json(proof)) + print(json.dumps(proof, sort_keys=True)) + elif args.command == "prepare": + manifest = prepare(args.run_id, args.version, args.build, args.macos, args.linux, args.notes_file, args.output, args.public_key, args.pub_date) + print(f"prepared Agent {manifest['version']} from {manifest['source_sha']}") + elif args.command == "reserve": + manifest = reserve(args.bundle, R2Store(args.endpoint, args.bucket), args.public_key) + print(f"reserved Agent {manifest['version']} manifest") + elif args.command == "sign": + manifest = sign(args.bundle, args.secret_key, args.public_key) + print(f"signed Agent {manifest['version']}") + elif args.command == "publish": + result = publish(args.bundle, R2Store(args.endpoint, args.bucket), args.public_key) + verify_live(args.bundle, args.public_key, args.base_url) + print(result + "; live metadata, signature, artifacts, ranges and redirects verified") + elif args.command == "verify-live": + manifest = verify_live(args.bundle, args.public_key, args.base_url) + print(f"verified live Agent {manifest['version']}") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, KeyError, TypeError, UnicodeError, tarfile.TarError, RecursionError, subprocess.SubprocessError): + # Do not expose subprocess stderr, request URLs/headers, or secret paths. + # PublishError messages are authored here and contain no credentials. + error = sys.exc_info()[1] + print(str(error) if isinstance(error, PublishError) else "publisher operation failed; inspect trusted inputs and credentials", file=sys.stderr) + raise SystemExit(1) diff --git a/services/agent-updates/publisher/test_minisign.py b/services/agent-updates/publisher/test_minisign.py new file mode 100644 index 000000000..2e0379e0d --- /dev/null +++ b/services/agent-updates/publisher/test_minisign.py @@ -0,0 +1,92 @@ +"""Verify the shared public wire fixture with the pinned real minisign CLI.""" + +import json +import hashlib +from pathlib import Path +import shutil +import subprocess +import tempfile +import unittest + +import publisher + + +FIXTURES = Path(__file__).with_name("fixtures") + + +@unittest.skipUnless(shutil.which("minisign"), "run in the pinned root CI shell for cryptographic tests") +class MinisignTests(unittest.TestCase): + def setUp(self): + self.data = (FIXTURES / "signed-envelope.json").read_bytes() + self.key = FIXTURES / "public-test-key.pub" + + def test_shared_client_fixture_verifies_prehashed_with_exact_comment(self): + manifest = publisher.minisign_verify(self.data, self.key) + self.assertEqual(manifest["version"], "0.0.2") + envelope = json.loads(self.data) + self.assertEqual(envelope["manifest"].encode(), (FIXTURES / "manifest.json").read_bytes()) + self.assertEqual(envelope["signature"].encode(), (FIXTURES / "manifest.minisig").read_bytes()) + + def test_tampered_signed_bytes_and_comment_are_rejected(self): + envelope = json.loads(self.data) + envelope["manifest"] = envelope["manifest"].replace("A test update.", "A fake update.") + with self.assertRaisesRegex(publisher.PublishError, "verification failed"): + publisher.minisign_verify(publisher.encode_json(envelope), self.key) + envelope = json.loads(self.data) + envelope["signature"] = envelope["signature"].replace("channel=stable", "channel=preview") + with self.assertRaisesRegex(publisher.PublishError, "comment"): + publisher.minisign_verify(publisher.encode_json(envelope), self.key) + + def test_real_temporary_key_reservation_signing_verification_and_retry(self): + class ReservationStore: + def __init__(self): + self.objects = {} + + def put(self, key, data, *, condition, content_type): + self.objects[key] = data + + def get(self, key, maximum=publisher.MAX_JSON): + return (self.objects[key], '"fixture-etag"') if key in self.objects else None + + # These disposable, offline test keys never leave the private tempdir. + # Production key generation is an owner operation outside this tool. + with tempfile.TemporaryDirectory(prefix="maple-agent-minisign-test-") as temporary: + bundle = Path(temporary) + key = bundle / "public-key.pub" + secret = bundle / "test-only-secret.key" + result = subprocess.run(["minisign", "-G", "-W", "-p", str(key), "-s", str(secret)], stdin=subprocess.DEVNULL, capture_output=True, timeout=30) + self.assertEqual(result.returncode, 0, "offline temporary test key generation failed") + (bundle / "manifest.json").write_bytes((FIXTURES / "manifest.json").read_bytes()) + payloads = bundle / "payloads" + payloads.mkdir() + for name in ("Maple-Agent.app.tar.gz", "Maple-Agent.dmg"): + (payloads / name).write_bytes(b"signed payload fixture bytes") + metadata = bundle / "metadata" + metadata.mkdir() + inventory = {} + for name in sorted({"provenance.json"} | publisher.MACOS_METADATA_NAMES): + data = ("offline signing test sidecar: " + name + "\n").encode() + (metadata / name).write_bytes(data) + inventory[name] = {"sha256": hashlib.sha256(data).hexdigest(), "size": len(data)} + (bundle / "metadata-inventory.json").write_bytes(publisher.encode_json(inventory)) + store = ReservationStore() + publisher.reserve(bundle, store) + self.assertIn("stable/releases/0.0.2/publication-manifest.json", store.objects) + publisher.sign(bundle, secret, key) + original = (bundle / "envelope.json").read_bytes() + self.assertEqual(publisher.minisign_verify(original, key)["version"], "0.0.2") + publisher.sign(bundle, secret, key) + self.assertEqual((bundle / "envelope.json").read_bytes(), original) + # A repeated signing attempt is deterministic: the exact trusted + # comment and untrusted comment contain no implicit timestamp. + (bundle / "envelope.json").unlink() + publisher.sign(bundle, secret, key) + self.assertEqual((bundle / "envelope.json").read_bytes(), original) + envelope = json.loads(original) + envelope["manifest"] = envelope["manifest"].replace("A test update.", "A fake update.") + with self.assertRaisesRegex(publisher.PublishError, "verification failed"): + publisher.minisign_verify(publisher.encode_json(envelope), key) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/agent-updates/publisher/test_publisher.py b/services/agent-updates/publisher/test_publisher.py new file mode 100644 index 000000000..9469bacab --- /dev/null +++ b/services/agent-updates/publisher/test_publisher.py @@ -0,0 +1,973 @@ +"""Offline publication recovery and untrusted artifact boundary tests. + +The fake store models conditional writes, interrupted uploads and concurrent +pointer changes. Its synthetic signatures exercise envelope plumbing only; +cryptographic verification remains an explicitly injected boundary here. +""" + +import base64 +import gzip +import hashlib +import importlib.util +import io +import json +from pathlib import Path +import plistlib +import tarfile +import tempfile +import unittest +from unittest.mock import patch +from urllib.parse import urlsplit + + +SPEC = importlib.util.spec_from_file_location("agent_publisher", Path(__file__).with_name("publisher.py")) +publisher = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(publisher) +SOURCE_SHA = "a" * 40 +PUBLIC_KEY = base64.b64encode(b"Ed" + bytes(40)).decode() +MACOS_METADATA = ( + "macos-SHA256SUMS", "macos-build-info.json", "macos-dmg-notarization-log.json", + "macos-manifest.json", "macos-notarization-log.json", "provenance.json", +) +LINUX_METADATA = ("linux-SHA256SUMS", "linux-build-info.json", "linux-manifest.json") + + +def synthetic_envelope(manifest): + signature = "\n".join(( + "untrusted comment: offline fixture, not a real signature", + base64.b64encode(b"ED" + bytes(72)).decode(), + "trusted comment: " + publisher.trusted_comment(manifest["version"]), + base64.b64encode(bytes(64)).decode(), + "", + )) + return publisher.encode_json({ + "manifest": publisher.encode_json(manifest).decode(), + "signature": signature, + }) + + +def fixture_verifier(data, public_key): + """A structural verifier: intentionally no cryptographic claims.""" + if publisher.public_key_text(public_key) != PUBLIC_KEY: + raise publisher.PublishError("unexpected verification key") + return publisher.parse_envelope(data)[1] + + +class FakeStore: + def __init__(self): + self.objects = {} + self.revisions = {} + self.calls = [] + self.fail_once = None + self.corrupt_once = None + self.before_pointer = None + + def set(self, key, data): + self.objects[key] = data + self.revisions[key] = self.revisions.get(key, 0) + 1 + + def etag(self, key): + return '"' + str(self.revisions[key]) + '"' + + def get(self, key, maximum=publisher.MAX_JSON): + self.calls.append(("get", key)) + if key not in self.objects: + return None + publisher.need(len(self.objects[key]) <= maximum, "oversized fake object") + return self.objects[key], self.etag(key) + + def same(self, key, digest, size): + self.calls.append(("same", key)) + if key not in self.objects: + return False + data = self.objects[key] + return len(data) == size and hashlib.sha256(data).hexdigest() == digest + + def put(self, key, data, *, condition, content_type="application/octet-stream"): + self.calls.append(("put", key, condition, content_type)) + if key == "stable/latest.json" and self.before_pointer: + callback, self.before_pointer = self.before_pointer, None + callback() + if key == self.fail_once: + self.fail_once = None + raise publisher.StoreError(503) + header, value = condition + if header == "If-None-Match": + if value != "*" or key in self.objects: + raise publisher.StoreError(412) + elif header == "If-Match": + if key not in self.objects or value != self.etag(key): + raise publisher.StoreError(412) + else: + raise AssertionError("unconditional write") + value = data.read_bytes() if isinstance(data, Path) else data + if key == self.corrupt_once: + self.corrupt_once = None + value = b"acknowledged but corrupted object" + self.set(key, value) + + +class FakeResponse(io.BytesIO): + def __init__(self, data, status=200, headers=None): + super().__init__(data) + self.status = status + self.headers = headers or {} + + +class FixtureOrigin: + """The exact public route contract, with no network or executable input.""" + + def __init__(self, bundle, manifest, envelope): + self.bundle, self.manifest, self.envelope = bundle, manifest, envelope + self.calls = [] + self.redirect = None + self.range_header = None + + def open(self, request, timeout): + self.assert_origin(request.full_url) + path = urlsplit(request.full_url).path + method = request.get_method() + range_header = request.get_header("Range") + self.calls.append((method, path, range_header)) + immutable = publisher.public_path(self.manifest["version"], "manifest.json") + if path in {"/agent/stable/latest.json", immutable} and method == "GET": + return FakeResponse(self.envelope) + records = [*self.manifest["artifacts"].values(), *self.manifest["downloads"].values()] + record = next((item for item in records if item["path"] == path), None) + if record: + data = (self.bundle / "payloads" / path.rsplit("/", 1)[1]).read_bytes() + if method == "HEAD": + return FakeResponse(b"", headers={"Content-Length": str(len(data))}) + if method == "GET" and range_header == "bytes=0-0": + return FakeResponse(data[:1], status=206, headers={ + "Content-Range": self.range_header or f"bytes 0-0/{len(data)}", + }) + if method == "GET" and range_header is None: + return FakeResponse(data) + if path == "/download/agent/stable/macos" and method == "GET": + return FakeResponse(b"", status=302, headers={ + "Location": self.redirect or self.manifest["downloads"]["macos"]["path"], + }) + return FakeResponse(b"404\n", status=404) + + def assert_origin(self, url): + if not url.startswith("https://updates.example/"): + raise AssertionError("unexpected live verification origin") + + +class PublicationTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.store = FakeStore() + self.bundle, self.manifest, self.envelope = self.make_bundle("1.2.3", "first") + + def make_bundle(self, version, name, notes="Release fixture", linux=False): + bundle = self.root / name + payloads = bundle / "payloads" + payloads.mkdir(parents=True) + artifacts = {} + binaries = [("agent.app.tar.gz", b"archive fixture"), ("agent.dmg", b"disk image fixture")] + if linux: + binaries.append(("agent.AppImage", b"AppImage fixture")) + for filename, data in binaries: + (payloads / filename).write_bytes(data) + artifacts[filename] = { + "path": publisher.public_path(version, filename), + "size": len(data), "sha256": hashlib.sha256(data).hexdigest(), + } + manifest = { + "format": "maple-agent-update-v1", "product": "maple-agent", + "channel": "stable", "version": version, "build": "42", + "source_sha": SOURCE_SHA, "pub_date": "2026-10-21T18:00:00Z", + "notes": notes, "artifacts": {"darwin-aarch64": artifacts["agent.app.tar.gz"]}, + "downloads": {"macos": artifacts["agent.dmg"]}, + } + if linux: + manifest["artifacts"]["linux-x86_64"] = artifacts["agent.AppImage"] + manifest["downloads"]["linux-appimage"] = artifacts["agent.AppImage"] + envelope = synthetic_envelope(manifest) + (bundle / "manifest.json").write_bytes(publisher.encode_json(manifest)) + (bundle / "envelope.json").write_bytes(envelope) + (bundle / "public-key.pub").write_text(PUBLIC_KEY + "\n") + metadata = bundle / "metadata" + metadata.mkdir() + for filename in (*MACOS_METADATA, *(LINUX_METADATA if linux else ())): + data = b"offline checksum fixture\n" if filename.endswith("SHA256SUMS") else \ + publisher.encode_json({"fixture": filename, "version": version}) + (metadata / filename).write_bytes(data) + self.index_metadata(bundle) + return bundle, manifest, envelope + + def reserve(self): + return publisher.reserve(self.bundle, self.store) + + def publish(self, bundle=None): + selected = bundle or self.bundle + return publisher.publish(selected, self.store, selected / "public-key.pub", + fixture_verifier, previous_verifier=fixture_verifier) + + def release_key(self, filename): + return publisher.release_key("1.2.3", filename) + + def metadata_key(self, filename="macos-build-info.json"): + return self.release_key("metadata") + "/" + filename + + def add_metadata(self, bundle=None, data=b'{"profile":"prod","version":"1.2.3"}\n'): + bundle = bundle or self.bundle + directory = bundle / "metadata" + directory.mkdir(exist_ok=True) + (directory / "macos-build-info.json").write_bytes(data) + return self.index_metadata(bundle) + + def index_metadata(self, bundle): + inventory = {path.name: {"sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "size": path.stat().st_size} + for path in sorted((bundle / "metadata").iterdir())} + (bundle / "metadata-inventory.json").write_bytes(publisher.encode_json(inventory)) + return inventory + + def test_reservation_precedes_artifacts_and_complete_manifest_precedes_pointer(self): + self.reserve() + self.assertEqual(self.publish(), "published") + writes = [call[1] for call in self.store.calls if call[0] == "put"] + self.assertEqual(writes, [ + self.release_key("publication-manifest.json"), + self.release_key("publication-metadata.json"), + self.release_key("agent.app.tar.gz"), self.release_key("agent.dmg"), + *[self.metadata_key(name) for name in MACOS_METADATA], + self.release_key("manifest.json"), "stable/latest.json", + ]) + self.assertEqual(self.store.objects["stable/latest.json"], self.envelope) + self.assertTrue(all(call[2] == ("If-None-Match", "*") for call in self.store.calls if call[0] == "put")) + + def test_each_payload_and_complete_manifest_are_read_back_before_promotion(self): + self.reserve() + self.publish() + complete = next(index for index, call in enumerate(self.store.calls) + if call[:2] == ("put", self.release_key("manifest.json"))) + pointer = next(index for index, call in enumerate(self.store.calls) + if call[:2] == ("put", "stable/latest.json")) + for name in ("agent.app.tar.gz", "agent.dmg"): + verification = next(index for index, call in enumerate(self.store.calls) + if call == ("same", self.release_key(name))) + self.assertLess(verification, complete) + marker_readback = [index for index, call in enumerate(self.store.calls) + if call == ("get", self.release_key("manifest.json")) and index > complete] + self.assertTrue(marker_readback) + self.assertLess(marker_readback[0], pointer) + + def test_acknowledged_corrupt_payload_cannot_be_marked_complete_or_promoted(self): + self.reserve() + self.store.corrupt_once = self.release_key("agent.app.tar.gz") + with self.assertRaises(publisher.PublishError): + self.publish() + self.assertNotIn(self.release_key("manifest.json"), self.store.objects) + self.assertNotIn("stable/latest.json", self.store.objects) + + def test_acknowledged_corrupt_complete_manifest_cannot_be_promoted(self): + self.reserve() + self.store.corrupt_once = self.release_key("manifest.json") + with self.assertRaises(publisher.PublishError): + self.publish() + self.assertNotIn("stable/latest.json", self.store.objects) + + def test_acknowledged_corrupt_reservation_cannot_receive_signing_receipt(self): + self.store.corrupt_once = self.release_key("publication-manifest.json") + with self.assertRaises(publisher.PublishError): + self.reserve() + self.assertFalse((self.bundle / "reservation.json").exists()) + + def test_private_metadata_inventory_is_reserved_and_files_verified_before_complete(self): + inventory = self.add_metadata() + self.reserve() + self.assertEqual(self.publish(), "published") + index = publisher.encode_json(inventory) + self.assertEqual(self.store.objects[self.release_key("publication-metadata.json")], index) + self.assertEqual(self.store.objects[self.metadata_key()], (self.bundle / "metadata/macos-build-info.json").read_bytes()) + receipt = json.loads((self.bundle / "reservation.json").read_text()) + self.assertEqual(receipt["metadata_inventory_sha256"], hashlib.sha256(index).hexdigest()) + complete = next(position for position, call in enumerate(self.store.calls) + if call[:2] == ("put", self.release_key("manifest.json"))) + for name in MACOS_METADATA: + verification = next(position for position, call in enumerate(self.store.calls) + if call == ("same", self.metadata_key(name))) + self.assertLess(verification, complete) + # Audit sidecars remain outside the signed public artifact inventory. + self.assertFalse(any("/metadata/" in record["path"] for record in + [*self.manifest["artifacts"].values(), *self.manifest["downloads"].values()])) + + def test_empty_metadata_inventory_cannot_reserve_a_production_release(self): + for path in (self.bundle / "metadata").iterdir(): + path.unlink() + (self.bundle / "metadata-inventory.json").write_bytes(publisher.encode_json({})) + with self.assertRaisesRegex(publisher.PublishError, "exact sidecars"): + self.reserve() + self.assertFalse(self.store.objects) + + def test_missing_or_subset_metadata_inventory_cannot_bypass_required_audit_data(self): + inventory = json.loads((self.bundle / "metadata-inventory.json").read_text()) + (self.bundle / "metadata-inventory.json").unlink() + with self.assertRaisesRegex(publisher.PublishError, "metadata has no inventory"): + self.reserve() + (self.bundle / "metadata").rename(self.bundle / "saved-metadata") + with self.assertRaisesRegex(publisher.PublishError, "metadata has no inventory"): + self.reserve() + (self.bundle / "saved-metadata").rename(self.bundle / "metadata") + for name in MACOS_METADATA: + with self.subTest(missing=name): + subset = {key: record for key, record in inventory.items() if key != name} + (self.bundle / "metadata-inventory.json").write_bytes(publisher.encode_json(subset)) + with self.assertRaisesRegex(publisher.PublishError, "exact sidecars"): + self.reserve() + self.assertFalse(self.store.objects) + + def test_metadata_inventory_exactly_tracks_linux_presence_in_signed_manifest(self): + for variant in ("missing-linux", "partial-linux", "unexpected-linux"): + with self.subTest(variant=variant): + bundle, _, _ = self.make_bundle("1.2.3", variant, linux=variant != "unexpected-linux") + if variant == "unexpected-linux": + for name in LINUX_METADATA: + (bundle / "metadata" / name).write_text("offline Linux metadata\n") + else: + for name in (LINUX_METADATA if variant == "missing-linux" else ("linux-manifest.json",)): + (bundle / "metadata" / name).unlink() + self.index_metadata(bundle) + with self.assertRaisesRegex(publisher.PublishError, "exact sidecars"): + publisher.reserve(bundle, self.store) + self.assertFalse(self.store.objects) + + def test_linux_bundle_includes_and_publishes_all_required_private_sidecars(self): + bundle, _, _ = self.make_bundle("1.2.3", "complete-linux", linux=True) + publisher.reserve(bundle, self.store) + self.assertEqual(self.publish(bundle), "published") + for name in (*MACOS_METADATA, *LINUX_METADATA): + self.assertEqual(self.store.objects[self.metadata_key(name)], (bundle / "metadata" / name).read_bytes()) + + def test_metadata_missing_corrupt_unchecked_and_symlink_files_fail_before_reservation(self): + for variant in ("missing", "corrupt", "unchecked", "symlink"): + with self.subTest(variant=variant): + bundle, _, _ = self.make_bundle("1.2.3", "metadata-" + variant) + self.add_metadata(bundle) + metadata = bundle / "metadata/macos-build-info.json" + if variant == "missing": + metadata.unlink() + elif variant == "corrupt": + metadata.write_bytes(b"different bytes") + elif variant == "unchecked": + (bundle / "metadata/linux-build-info.json").write_text("{}") + else: + metadata.unlink() + metadata.symlink_to(bundle / "manifest.json") + with self.assertRaises((publisher.PublishError, OSError)): + publisher.reserve(bundle, self.store) + self.assertFalse(self.store.objects) + + def test_private_metadata_directory_requires_index_and_cannot_be_a_symlink(self): + self.add_metadata() + index = (self.bundle / "metadata-inventory.json").read_bytes() + (self.bundle / "metadata-inventory.json").unlink() + with self.assertRaisesRegex(publisher.PublishError, "metadata has no inventory"): + self.reserve() + (self.bundle / "metadata-inventory.json").write_bytes(index) + directory = self.bundle / "metadata" + directory.rename(self.bundle / "other-metadata") + directory.symlink_to(self.bundle / "other-metadata", target_is_directory=True) + with self.assertRaises(publisher.PublishError): + self.reserve() + self.assertFalse(self.store.objects) + + def test_acknowledged_corrupt_metadata_index_cannot_receive_signing_receipt(self): + self.add_metadata() + self.store.corrupt_once = self.release_key("publication-metadata.json") + with self.assertRaisesRegex(publisher.PublishError, "reserved metadata read-back differs"): + self.reserve() + self.assertFalse((self.bundle / "reservation.json").exists()) + self.assertNotIn(self.release_key("manifest.json"), self.store.objects) + self.assertNotIn("stable/latest.json", self.store.objects) + + def test_publication_requires_reserved_metadata_index_before_writing_payloads(self): + self.add_metadata() + self.reserve() + del self.store.objects[self.release_key("publication-metadata.json")] + self.store.calls.clear() + with self.assertRaisesRegex(publisher.PublishError, "metadata must be reserved"): + self.publish() + self.assertFalse(any(call[0] == "put" for call in self.store.calls)) + + def test_unallowlisted_metadata_or_invalid_hash_index_is_rejected_before_reservation(self): + valid = self.add_metadata() + invalid = ( + {**valid, "unexpected.json": valid["macos-build-info.json"]}, + {**valid, "../macos-build-info.json": valid["macos-build-info.json"]}, + {**valid, "macos-build-info.json": {**valid["macos-build-info.json"], "sha256": "not-a-digest"}}, + {**valid, "macos-build-info.json": {**valid["macos-build-info.json"], "size": True}}, + {**valid, "macos-build-info.json": {**valid["macos-build-info.json"], "extra": "unexpected"}}, + ) + for inventory in invalid: + with self.subTest(inventory=inventory): + (self.bundle / "metadata-inventory.json").write_bytes(publisher.encode_json(inventory)) + with self.assertRaises(publisher.PublishError): + self.reserve() + self.assertFalse(self.store.objects) + + def test_metadata_index_cannot_change_after_reservation_even_with_same_signed_manifest(self): + self.add_metadata() + self.reserve() + self.add_metadata(data=b'{"profile":"prod","version":"1.2.3","changed":true}\n') + self.store.calls.clear() + with self.assertRaises(publisher.PublishError): + self.publish() + self.assertFalse(any(call[0] == "put" for call in self.store.calls)) + self.assertNotIn("stable/latest.json", self.store.objects) + + def test_metadata_upload_failure_leaves_no_complete_release_and_resumes_identically(self): + self.add_metadata() + self.reserve() + self.store.fail_once = self.metadata_key() + with self.assertRaises(publisher.StoreError): + self.publish() + self.assertNotIn(self.release_key("manifest.json"), self.store.objects) + self.assertNotIn("stable/latest.json", self.store.objects) + self.assertEqual(self.publish(), "published") + self.assertEqual(self.store.revisions[self.release_key("agent.app.tar.gz")], 1) + self.assertEqual(self.store.objects[self.metadata_key()], (self.bundle / "metadata/macos-build-info.json").read_bytes()) + + def test_acknowledged_corrupt_private_metadata_cannot_be_marked_complete(self): + self.add_metadata() + self.reserve() + self.store.corrupt_once = self.metadata_key() + with self.assertRaises(publisher.PublishError): + self.publish() + self.assertNotIn(self.release_key("manifest.json"), self.store.objects) + self.assertNotIn("stable/latest.json", self.store.objects) + + def test_published_retry_repairs_missing_private_metadata_without_pointer_rewrite(self): + self.add_metadata() + self.reserve() + self.publish() + del self.store.objects[self.metadata_key()] + self.store.calls.clear() + self.assertEqual(self.publish(), "already-published") + self.assertEqual(self.store.objects[self.metadata_key()], (self.bundle / "metadata/macos-build-info.json").read_bytes()) + self.assertEqual(self.store.revisions["stable/latest.json"], 1) + self.assertFalse(any(call[:2] == ("put", "stable/latest.json") for call in self.store.calls)) + + def test_published_retry_rejects_existing_corrupt_private_metadata(self): + self.add_metadata() + self.reserve() + self.publish() + self.store.set(self.metadata_key(), b"corrupt audit metadata") + self.store.calls.clear() + with self.assertRaisesRegex(publisher.PublishError, "immutable object differs"): + self.publish() + self.assertEqual(self.store.objects[self.metadata_key()], b"corrupt audit metadata") + self.assertEqual(self.store.revisions["stable/latest.json"], 1) + self.assertFalse(any(call[:2] == ("put", "stable/latest.json") for call in self.store.calls)) + + def test_retry_resumes_partial_upload_without_discoverable_incomplete_release(self): + self.reserve() + self.store.fail_once = self.release_key("agent.dmg") + with self.assertRaises(publisher.StoreError): + self.publish() + first = self.store.objects[self.release_key("agent.app.tar.gz")] + self.assertNotIn(self.release_key("manifest.json"), self.store.objects) + self.assertNotIn("stable/latest.json", self.store.objects) + self.assertEqual(self.publish(), "published") + self.assertEqual(self.store.objects[self.release_key("agent.app.tar.gz")], first) + self.assertEqual(self.store.revisions[self.release_key("agent.app.tar.gz")], 1) + self.assertIn(("same", self.release_key("agent.app.tar.gz")), self.store.calls) + + def test_retry_after_complete_manifest_before_promotion_reuses_exact_envelope(self): + self.reserve() + self.store.fail_once = "stable/latest.json" + with self.assertRaises(publisher.StoreError): + self.publish() + self.assertEqual(self.store.objects[self.release_key("manifest.json")], self.envelope) + self.assertNotIn("stable/latest.json", self.store.objects) + self.assertEqual(self.publish(), "published") + self.assertEqual(self.store.revisions[self.release_key("manifest.json")], 1) + + def test_existing_immutable_different_bytes_fail_before_pointer(self): + self.reserve() + self.store.set(self.release_key("agent.app.tar.gz"), b"different archive") + with self.assertRaisesRegex(publisher.PublishError, "immutable object differs"): + self.publish() + self.assertNotIn("stable/latest.json", self.store.objects) + self.assertEqual(self.store.objects[self.release_key("agent.app.tar.gz")], b"different archive") + + def test_existing_complete_manifest_different_bytes_are_not_overwritten(self): + self.reserve() + self.store.set(self.release_key("manifest.json"), b"other envelope") + with self.assertRaisesRegex(publisher.PublishError, "immutable object differs"): + self.publish() + self.assertNotIn("stable/latest.json", self.store.objects) + + def test_pointer_race_preserves_competing_promotion(self): + _, old_manifest, old_envelope = self.make_bundle("1.2.2", "old") + _, newer_manifest, newer_envelope = self.make_bundle("1.2.4", "newer") + self.assertLess(publisher.version_tuple(old_manifest["version"]), publisher.version_tuple(newer_manifest["version"])) + self.store.set("stable/latest.json", old_envelope) + self.reserve() + self.store.before_pointer = lambda: self.store.set("stable/latest.json", newer_envelope) + with self.assertRaisesRegex(publisher.PublishError, "pointer changed concurrently"): + self.publish() + self.assertEqual(self.store.objects["stable/latest.json"], newer_envelope) + pointer_write = [call for call in self.store.calls if call[:2] == ("put", "stable/latest.json")][-1] + self.assertEqual(pointer_write[2], ("If-Match", '"1"')) + + def test_creation_race_preserves_operator_pointer(self): + _, _, newer_envelope = self.make_bundle("1.2.4", "newer") + self.reserve() + self.store.before_pointer = lambda: self.store.set("stable/latest.json", newer_envelope) + with self.assertRaisesRegex(publisher.PublishError, "pointer changed concurrently"): + self.publish() + self.assertEqual(self.store.objects["stable/latest.json"], newer_envelope) + + def test_identical_published_release_is_verified_no_op(self): + self.reserve() + self.publish() + revisions = self.store.revisions.copy() + self.store.calls.clear() + self.assertEqual(self.publish(), "already-published") + self.assertFalse(any(call[:2] == ("put", "stable/latest.json") for call in self.store.calls)) + self.assertEqual(self.store.revisions, revisions) + self.assertTrue({ + self.release_key("agent.app.tar.gz"), self.release_key("agent.dmg"), + } <= {call[1] for call in self.store.calls if call[0] == "same"}) + + def test_identical_pointer_repairs_missing_artifact_without_rewriting_pointer(self): + self.reserve() + self.publish() + del self.store.objects[self.release_key("agent.app.tar.gz")] + self.store.calls.clear() + self.assertEqual(self.publish(), "already-published") + self.assertEqual(self.store.objects[self.release_key("agent.app.tar.gz")], + (self.bundle / "payloads/agent.app.tar.gz").read_bytes()) + self.assertEqual(self.store.objects["stable/latest.json"], self.envelope) + self.assertEqual(self.store.revisions["stable/latest.json"], 1) + self.assertFalse(any(call[:2] == ("put", "stable/latest.json") for call in self.store.calls)) + + def test_identical_pointer_does_not_overwrite_corrupt_existing_artifact(self): + self.reserve() + self.publish() + self.store.set(self.release_key("agent.app.tar.gz"), b"corrupt archive") + self.store.calls.clear() + with self.assertRaisesRegex(publisher.PublishError, "immutable object differs"): + self.publish() + self.assertEqual(self.store.objects[self.release_key("agent.app.tar.gz")], b"corrupt archive") + self.assertEqual(self.store.objects["stable/latest.json"], self.envelope) + self.assertEqual(self.store.revisions["stable/latest.json"], 1) + self.assertFalse(any(call[:2] == ("put", "stable/latest.json") for call in self.store.calls)) + + def test_identical_pointer_repairs_missing_complete_manifest_without_pointer_rewrite(self): + self.reserve() + self.publish() + del self.store.objects[self.release_key("manifest.json")] + self.store.calls.clear() + self.assertEqual(self.publish(), "already-published") + self.assertEqual(self.store.objects[self.release_key("manifest.json")], self.envelope) + self.assertEqual(self.store.objects["stable/latest.json"], self.envelope) + self.assertEqual(self.store.revisions["stable/latest.json"], 1) + self.assertFalse(any(call[:2] == ("put", "stable/latest.json") for call in self.store.calls)) + + def test_identical_pointer_does_not_overwrite_corrupt_complete_manifest(self): + self.reserve() + self.publish() + self.store.set(self.release_key("manifest.json"), b"corrupt complete manifest") + self.store.calls.clear() + with self.assertRaisesRegex(publisher.PublishError, "immutable object differs"): + self.publish() + self.assertEqual(self.store.objects[self.release_key("manifest.json")], b"corrupt complete manifest") + self.assertEqual(self.store.revisions["stable/latest.json"], 1) + self.assertFalse(any(call[:2] == ("put", "stable/latest.json") for call in self.store.calls)) + + def test_equal_or_lower_version_with_different_bytes_is_rejected(self): + self.reserve() + self.publish() + for version in ("1.2.3", "1.2.2"): + with self.subTest(version=version): + bundle, _, _ = self.make_bundle(version, "other-" + version, "changed notes") + if version == "1.2.2": + publisher.reserve(bundle, self.store) + self.store.calls.clear() + with self.assertRaisesRegex(publisher.PublishError, "reserved before|must advance"): + self.publish(bundle) + self.assertFalse(any(call[0] == "put" for call in self.store.calls)) + + def test_equal_version_different_envelope_is_not_treated_as_published_no_op(self): + self.reserve() + self.publish() + envelope = json.loads(self.envelope) + envelope["signature"] = envelope["signature"].replace("offline fixture, not a real signature", "different fixture comment") + (self.bundle / "envelope.json").write_bytes(publisher.encode_json(envelope)) + self.store.calls.clear() + with self.assertRaisesRegex(publisher.PublishError, "release version must advance"): + self.publish() + self.assertFalse(any(call[0] == "put" for call in self.store.calls)) + + def test_reservation_rejects_changed_notes_or_timestamp(self): + self.reserve() + other, _, _ = self.make_bundle("1.2.3", "changed", "changed notes") + with self.assertRaisesRegex(publisher.PublishError, "immutable object differs"): + publisher.reserve(other, self.store) + self.assertFalse((other / "reservation.json").exists()) + + def test_reserved_complete_envelope_is_reused_on_retry(self): + self.reserve() + self.publish() + retry, _, _ = self.make_bundle("1.2.3", "retry") + (retry / "envelope.json").unlink() + publisher.reserve(retry, self.store) + self.assertEqual((retry / "envelope.json").read_bytes(), self.envelope) + self.assertEqual(self.publish(retry), "already-published") + + def test_publish_requires_reservation_and_verification_before_mutations(self): + with self.assertRaisesRegex(publisher.PublishError, "must be reserved"): + self.publish() + self.assertFalse(self.store.objects) + self.reserve() + self.store.calls.clear() + wrong_key = self.root / "other-key.pub" + wrong_key.write_text(base64.b64encode(b"Ed" + bytes([1]) * 40).decode() + "\n") + with self.assertRaisesRegex(publisher.PublishError, "key differs"): + publisher.publish(self.bundle, self.store, wrong_key, fixture_verifier) + self.assertFalse(any(call[0] == "put" for call in self.store.calls)) + + def test_manifest_byte_identity_is_checked_not_only_json_equivalence(self): + self.reserve() + (self.bundle / "manifest.json").write_text(json.dumps(self.manifest, indent=2)) + with self.assertRaisesRegex(publisher.PublishError, "manifest bytes differ"): + self.publish() + self.assertNotIn("stable/latest.json", self.store.objects) + + def test_invalid_prepared_inventory_is_rejected_before_reservation(self): + (self.bundle / "payloads/unexpected.txt").write_text("extra") + with self.assertRaisesRegex(publisher.PublishError, "inventory differs"): + self.reserve() + self.assertFalse(self.store.objects) + + def test_corrupt_or_symlink_payload_is_rejected_before_reservation(self): + payload = self.bundle / "payloads/agent.app.tar.gz" + payload.write_bytes(b"corrupt") + with self.assertRaisesRegex(publisher.PublishError, "hash differs"): + self.reserve() + payload.unlink() + payload.symlink_to(self.bundle / "manifest.json") + with self.assertRaises(OSError): + self.reserve() + self.assertFalse(self.store.objects) + + def test_live_verification_covers_public_feed_immutable_head_range_and_download_routes(self): + origin = FixtureOrigin(self.bundle, self.manifest, self.envelope) + with patch.object(publisher, "minisign_verify", fixture_verifier), \ + patch.object(publisher.urllib.request, "build_opener", return_value=origin): + result = publisher.verify_live(self.bundle, self.bundle / "public-key.pub", "https://updates.example") + self.assertEqual(result, self.manifest) + self.assertIn(("GET", "/agent/stable/latest.json", None), origin.calls) + self.assertIn(("GET", publisher.public_path("1.2.3", "manifest.json"), None), origin.calls) + self.assertIn(("GET", "/download/agent/stable/macos", None), origin.calls) + for record in [*self.manifest["artifacts"].values(), *self.manifest["downloads"].values()]: + for method, range_header in (("HEAD", None), ("GET", "bytes=0-0"), ("GET", None)): + self.assertIn((method, record["path"], range_header), origin.calls) + + def test_live_verification_rejects_foreign_or_wrong_download_redirect(self): + for redirect in ("https://foreign.example/app.dmg", "/agent/stable/releases/1.2.4/app.dmg"): + with self.subTest(redirect=redirect): + origin = FixtureOrigin(self.bundle, self.manifest, self.envelope) + origin.redirect = redirect + with patch.object(publisher, "minisign_verify", fixture_verifier), \ + patch.object(publisher.urllib.request, "build_opener", return_value=origin), \ + self.assertRaisesRegex(publisher.PublishError, "download redirect differs"): + publisher.verify_live(self.bundle, self.bundle / "public-key.pub", "https://updates.example") + + def test_live_verification_rejects_wrong_range_response(self): + origin = FixtureOrigin(self.bundle, self.manifest, self.envelope) + origin.range_header = "bytes 0-0/999999" + with patch.object(publisher, "minisign_verify", fixture_verifier), \ + patch.object(publisher.urllib.request, "build_opener", return_value=origin), \ + self.assertRaisesRegex(publisher.PublishError, "range request failed"): + publisher.verify_live(self.bundle, self.bundle / "public-key.pub", "https://updates.example") + + def test_release_filenames_match_worker_ambiguity_and_length_limits(self): + for filename in ("agent..app.tar.gz", "a" * 201 + ".dmg", "../app.dmg", "app%2fdmg"): + with self.subTest(filename=filename), self.assertRaises(publisher.PublishError): + publisher.release_key("1.2.3", filename) + + +class PackageBoundaryTests(unittest.TestCase): + def setUp(self): + self.temporary = tempfile.TemporaryDirectory() + self.addCleanup(self.temporary.cleanup) + self.directory = Path(self.temporary.name) + profiles = json.loads((publisher.ROOT / "apps/maple-agent/release-profiles.json").read_text()) + metadata_spec = importlib.util.spec_from_file_location( + "fixture_release_info", publisher.ROOT / "apps/maple-agent/scripts/release-info.py") + release_info = importlib.util.module_from_spec(metadata_spec) + metadata_spec.loader.exec_module(release_info) + self.info = {field: profiles["prod"][field] for field in release_info.PUBLIC_FIELDS} + self.info.update({"profile": "prod", "version": "1.2.3", + "source_sha": SOURCE_SHA, "git_revision": SOURCE_SHA[:9]}) + self.stem = "maple-agent-prod-1.2.3-" + SOURCE_SHA[:9] + "-macos-aarch64" + self.metadata = {"schema_version": 1, "profile": "prod", "platform": "macos-aarch64", + "source_sha": SOURCE_SHA, "version": "1.2.3", "package": self.stem + ".dmg", + "distribution": "master", "macos_signing": "developer-id-notarized"} + (self.directory / "build-info.json").write_bytes(publisher.encode_json(self.info)) + (self.directory / "manifest.json").write_bytes(publisher.encode_json(self.metadata)) + (self.directory / (self.stem + ".dmg")).write_bytes(b"fake disk image; never execute") + self.write_archive() + for name in ("notarization-log.json", "dmg-notarization-log.json"): + (self.directory / name).write_bytes(publisher.encode_json({"status": "Accepted", "issues": None})) + self.checksums() + self.attested = [] + + def write_archive(self, plist=None, entries=None): + if plist is None: + plist = {"CFBundleIdentifier": "cloud.opensecret.maple.agent", + "CFBundleShortVersionString": "1.2.3", "CFBundleVersion": "42"} + if entries is None: + entries = [("Maple Agent.app/Contents/Info.plist", tarfile.REGTYPE, plistlib.dumps(plist))] + with tarfile.open(self.directory / (self.stem + ".app.tar.gz"), "w:gz") as archive: + for name, kind, data in entries: + member = tarfile.TarInfo(name) + member.type = kind + member.mode = 0o644 + if kind == tarfile.REGTYPE: + member.size = len(data) + archive.addfile(member, io.BytesIO(data)) + else: + member.linkname = "Other.plist" + archive.addfile(member) + + def checksums(self): + (self.directory / "SHA256SUMS").write_text("".join( + hashlib.sha256(path.read_bytes()).hexdigest() + " " + path.name + "\n" + for path in sorted(self.directory.iterdir()) if path.name != "SHA256SUMS" and path.is_file() + )) + + def validate(self): + return publisher.validate_package(self.directory, "macos-aarch64", SOURCE_SHA, "1.2.3", + attestor=lambda path, source: self.attested.append((path.name, source)), + expected_build="42") + + def test_exact_final_inventory_is_hashed_and_each_file_attested_without_execution(self): + result = self.validate() + self.assertEqual([path.name for path, _ in result], [self.stem + ".dmg", self.stem + ".app.tar.gz"]) + self.assertEqual({name for name, _ in self.attested}, {path.name for path in self.directory.iterdir()}) + self.assertTrue(all(source == SOURCE_SHA for _, source in self.attested)) + + def test_extra_files_missing_files_and_symlinks_are_rejected(self): + extra = self.directory / "unexpected.json" + extra.write_text("{}") + with self.assertRaisesRegex(publisher.PublishError, "unexpected or missing"): + self.validate() + extra.unlink() + target = self.directory / "notarization-log.json" + target.unlink() + with self.assertRaisesRegex(publisher.PublishError, "unexpected or missing"): + self.validate() + target.symlink_to(self.directory / "dmg-notarization-log.json") + with self.assertRaises(OSError): + self.validate() + self.assertFalse(self.attested) + + def test_corrupt_checksum_duplicate_and_traversal_records_are_rejected(self): + checksums = self.directory / "SHA256SUMS" + original = checksums.read_text() + for value in (original.replace(original[:64], "b" * 64, 1), + original + original.splitlines()[0] + "\n", + "a" * 64 + " ../outside\n"): + with self.subTest(value=value): + checksums.write_text(value) + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_unsigned_or_dev_identity_is_rejected_even_with_valid_checksums(self): + self.metadata["distribution"] = "unsigned-pr" + (self.directory / "manifest.json").write_bytes(publisher.encode_json(self.metadata)) + self.checksums() + with self.assertRaisesRegex(publisher.PublishError, "invalid final package metadata"): + self.validate() + self.info["profile"] = "dev" + (self.directory / "build-info.json").write_bytes(publisher.encode_json(self.info)) + self.checksums() + with self.assertRaisesRegex(publisher.PublishError, "Prod build identity"): + self.validate() + self.assertFalse(self.attested) + + def test_rejected_notarization_and_duplicate_metadata_keys_are_rejected(self): + (self.directory / "notarization-log.json").write_bytes(publisher.encode_json({"status": "Rejected", "issues": []})) + self.checksums() + with self.assertRaisesRegex(publisher.PublishError, "notarization was not accepted"): + self.validate() + (self.directory / "build-info.json").write_bytes(b'{"profile":"prod","profile":"prod"}') + with self.assertRaisesRegex(publisher.PublishError, "duplicate JSON field"): + self.validate() + self.assertFalse(self.attested) + + def test_archive_build_version_and_bundle_identity_match_trusted_publication(self): + for field, value in (("CFBundleVersion", "43"), ("CFBundleShortVersionString", "1.2.4"), + ("CFBundleIdentifier", "cloud.opensecret.maple")): + with self.subTest(field=field): + plist = {"CFBundleIdentifier": "cloud.opensecret.maple.agent", + "CFBundleShortVersionString": "1.2.3", "CFBundleVersion": "42", field: value} + self.write_archive(plist) + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_archive_duplicate_or_missing_info_plist_is_rejected_without_extraction(self): + entry = ("Maple Agent.app/Contents/Info.plist", tarfile.REGTYPE, + plistlib.dumps({"CFBundleIdentifier": "cloud.opensecret.maple.agent", + "CFBundleShortVersionString": "1.2.3", "CFBundleVersion": "42"})) + for entries in ([entry, entry], [("Other.app/Contents/Info.plist", *entry[1:])], []): + with self.subTest(entries=len(entries)): + self.write_archive(entries=entries) + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_archive_nonregular_or_oversized_info_plist_is_rejected(self): + for kind, data in ((tarfile.SYMTYPE, b""), (tarfile.LNKTYPE, b""), + (tarfile.FIFOTYPE, b""), (tarfile.REGTYPE, bytes(64 * 1024 + 1))): + with self.subTest(kind=kind, size=len(data)): + self.write_archive(entries=[("Maple Agent.app/Contents/Info.plist", kind, data)]) + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_archive_invalid_plist_or_nondictionary_plist_is_rejected(self): + for data in (b"invalid plist", b'broken', + plistlib.dumps(["unexpected", "plist", "shape"])): + with self.subTest(data=data[:20]): + self.write_archive(entries=[("Maple Agent.app/Contents/Info.plist", tarfile.REGTYPE, data)]) + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_archive_invalid_container_is_rejected_with_bounded_error(self): + (self.directory / (self.stem + ".app.tar.gz")).write_bytes(b"not a gzip or tar archive") + self.checksums() + with self.assertRaises(publisher.PublishError): + self.validate() + self.assertFalse(self.attested) + + def test_archive_oversized_member_is_rejected_before_reading_its_body(self): + member = tarfile.TarInfo("Maple Agent.app/Contents/oversized") + member.size = publisher.MAX_ARTIFACT + 1 + # Only the header exists. The size gate must reject it before a stream + # parser tries to skip/decompress the declared multi-gigabyte body. + data = gzip.compress(member.tobuf() + bytes(1024)) + (self.directory / (self.stem + ".app.tar.gz")).write_bytes(data) + self.checksums() + with self.assertRaisesRegex(publisher.PublishError, "bounded inventory|oversized member"): + self.validate() + self.assertFalse(self.attested) + + def test_archive_extension_headers_are_bounded_before_tarfile_parses_them(self): + for kind in (tarfile.XHDTYPE, tarfile.XGLTYPE, tarfile.SOLARIS_XHDTYPE, + tarfile.GNUTYPE_LONGNAME, tarfile.GNUTYPE_LONGLINK): + with self.subTest(kind=kind): + member = tarfile.TarInfo("extension") + member.type, member.size = kind, 64 * 1024 + 1 + data = gzip.compress(member.tobuf() + bytes(1024)) + (self.directory / (self.stem + ".app.tar.gz")).write_bytes(data) + self.checksums() + with self.assertRaisesRegex(publisher.PublishError, "extension metadata exceeds bounds"): + self.validate() + self.assertFalse(self.attested) + + def test_archive_sparse_member_is_not_interpreted_as_an_app_payload(self): + member = tarfile.TarInfo("sparse") + member.type = tarfile.GNUTYPE_SPARSE + data = gzip.compress(member.tobuf() + bytes(1024)) + (self.directory / (self.stem + ".app.tar.gz")).write_bytes(data) + self.checksums() + with self.assertRaisesRegex(publisher.PublishError, "sparse app archive members"): + self.validate() + self.assertFalse(self.attested) + + def test_pax_sparse_variants_are_rejected_before_stdlib_sparse_map_parsing(self): + cases = ( + ({"GNU.sparse.major": "1", "GNU.sparse.minor": "0", "GNU.sparse.realsize": "999999999"}, + "_proc_gnusparse_10", b"999999999\n"), + ({"GNU.sparse.map": "0,999999999", "GNU.sparse.size": "999999999"}, + "_proc_gnusparse_01", b""), + ({"GNU.sparse.size": "999999999", "GNU.sparse.numblocks": "999999999"}, + "_proc_gnusparse_00", b""), + ) + for headers, parser, body in cases: + with self.subTest(parser=parser): + member = tarfile.TarInfo("Maple Agent.app/Contents/sparse") + member.pax_headers, member.size = headers, len(body) + with tarfile.open(self.directory / (self.stem + ".app.tar.gz"), "w:gz") as archive: + archive.addfile(member, io.BytesIO(body)) + self.checksums() + # Keep even a regression safe: reaching the standard library's + # sparse parser fails immediately before attacker map counts + # can drive its list allocation or block-reading loops. + with patch.object(tarfile.TarInfo, parser, side_effect=AssertionError("stdlib sparse parser reached")), \ + self.assertRaisesRegex(publisher.PublishError, "sparse app archive members are unsupported"): + self.validate() + self.assertFalse(self.attested) + + +class TrustedRunTests(unittest.TestCase): + def setUp(self): + self.run = { + "id": 123, "run_attempt": 2, "run_number": 42, + "repository": {"full_name": publisher.REPOSITORY}, + "head_repository": {"full_name": publisher.REPOSITORY}, + "path": publisher.WORKFLOW, "head_branch": "master", "event": "push", + "status": "completed", "conclusion": "success", "head_sha": SOURCE_SHA, + } + self.names = {"Signed macOS (prod)", "Verify downloaded macOS (prod)"} + + def jobs(self, names=None): + jobs = [{"name": name, "status": "completed", "conclusion": "success"} for name in sorted(names or self.names)] + return {"total_count": len(jobs), "jobs": jobs} + + def test_macos_requires_concrete_completed_prod_jobs(self): + self.assertEqual(publisher.validate_run(self.run, self.jobs()), self.run) + for conclusion in ("skipped", "failure", "cancelled", None): + jobs = self.jobs() + jobs["jobs"][0]["conclusion"] = conclusion + with self.subTest(conclusion=conclusion), self.assertRaises(publisher.PublishError): + publisher.validate_run(self.run, jobs) + with self.assertRaisesRegex(publisher.PublishError, "required Prod package job"): + publisher.validate_run(self.run, self.jobs({"Select Agent packaging"})) + + def test_linux_is_optional_but_each_linux_gate_is_required_when_selected(self): + with self.assertRaises(publisher.PublishError): + publisher.validate_run(self.run, self.jobs(), linux=True) + names = self.names | {"Linux AppImage (prod)", "Attest Linux packages (prod)", "Verify downloaded Linux (prod)"} + publisher.validate_run(self.run, self.jobs(names), linux=True) + for name in names - self.names: + with self.subTest(name=name), self.assertRaises(publisher.PublishError): + publisher.validate_run(self.run, self.jobs(names - {name}), linux=True) + + def test_incomplete_duplicate_or_untrusted_job_inventory_fails_closed(self): + jobs = self.jobs() + jobs["total_count"] += 1 + with self.assertRaisesRegex(publisher.PublishError, "incomplete"): + publisher.validate_run(self.run, jobs) + jobs = self.jobs() + jobs["jobs"].append(jobs["jobs"][0].copy()) + jobs["total_count"] += 1 + with self.assertRaises(publisher.PublishError): + publisher.validate_run(self.run, jobs) + for field, value in (("head_branch", "feature"), ("event", "pull_request"), + ("path", ".github/workflows/release.yml"), ("head_sha", "a" * 7)): + with self.subTest(field=field), self.assertRaises(publisher.PublishError): + publisher.validate_run({**self.run, field: value}, self.jobs()) + with self.assertRaisesRegex(publisher.PublishError, "untrusted build repository"): + publisher.validate_run({**self.run, "head_repository": {"full_name": "other/Maple"}}, self.jobs()) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/agent-updates/publisher/test_trust.py b/services/agent-updates/publisher/test_trust.py new file mode 100644 index 000000000..bcd9bbf42 --- /dev/null +++ b/services/agent-updates/publisher/test_trust.py @@ -0,0 +1,175 @@ +"""Trust boundary tests, independent of GitHub, credentials and signing keys.""" + +import base64 +from copy import deepcopy +import json +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + +import publisher as p + + +class TrustTests(unittest.TestCase): + def setUp(self): + self.run = { + "repository": {"full_name": p.REPOSITORY}, + "head_repository": {"full_name": p.REPOSITORY}, + "path": p.WORKFLOW, "head_branch": "master", "event": "push", + "status": "completed", "conclusion": "failure", "head_sha": "a" * 40, + "id": 12, "run_attempt": 2, "run_number": 42, + "created_at": "2026-10-21T01:02:03Z", + } + self.jobs = {"total_count": 5, "jobs": [ + {"name": name, "status": "completed", "conclusion": "success"} + for name in ("Signed macOS (prod)", "Verify downloaded macOS (prod)", + "Linux AppImage (prod)", "Attest Linux packages (prod)", + "Verify downloaded Linux (prod)") + ]} + + def test_actual_prod_consumers_gate_even_if_dev_failed(self): + self.assertEqual(p.validate_run(self.run, self.jobs, True), self.run) + + def test_skipped_failed_missing_duplicate_and_incomplete_jobs_fail(self): + cases = [] + for conclusion in ("skipped", "failure", "cancelled", "neutral", None): + jobs = deepcopy(self.jobs) + jobs["jobs"][1]["conclusion"] = conclusion + cases.append(jobs) + missing = deepcopy(self.jobs) + missing["jobs"].pop(1) + missing["total_count"] -= 1 + cases.append(missing) + duplicate = deepcopy(self.jobs) + duplicate["jobs"].append(deepcopy(duplicate["jobs"][1])) + duplicate["total_count"] += 1 + cases.append(duplicate) + incomplete = deepcopy(self.jobs) + incomplete["total_count"] += 1 + cases.append(incomplete) + for jobs in cases: + with self.subTest(jobs=jobs), self.assertRaises(p.PublishError): + p.validate_run(self.run, jobs) + + def test_linux_is_required_only_when_requested(self): + self.jobs["jobs"][4]["conclusion"] = "failure" + p.validate_run(self.run, self.jobs, False) + with self.assertRaises(p.PublishError): + p.validate_run(self.run, self.jobs, True) + + def test_fork_pull_request_branch_and_other_workflow_fail(self): + replacements = { + "repository": {"full_name": "elsewhere/Maple"}, + "head_repository": {"full_name": "elsewhere/Maple"}, + "event": "pull_request", "head_branch": "feature", + "path": ".github/workflows/other.yml", "head_sha": "A" * 40, + "status": "in_progress", "run_attempt": 0, + } + for key, value in replacements.items(): + run = {**self.run, key: value} + with self.subTest(key=key), self.assertRaises(p.PublishError): + p.validate_run(run, self.jobs) + + def test_trust_uses_latest_jobs_including_successful_prior_attempt_and_final_artifact(self): + artifacts = {"total_count": 1, "artifacts": [ + {"name": "maple-agent-prod-macos-aarch64-12", "expired": False} + ]} + with patch.object(p, "gh_json", side_effect=[self.run, self.jobs, artifacts]) as api: + proof = p.trust_run("12") + self.assertEqual(proof["build"], "42") + self.assertIn("/jobs?filter=latest", api.call_args_list[1].args[0]) + self.assertEqual(proof["run_attempt"], 2) + self.assertEqual(proof["artifacts"]["macos"], "maple-agent-prod-macos-aarch64-12") + artifacts["artifacts"][0]["name"] += "-unsigned" + with patch.object(p, "gh_json", side_effect=[self.run, self.jobs, artifacts]), self.assertRaises(p.PublishError): + p.trust_run("12") + + def test_durable_audit_ignores_diagnostic_attempt_but_binds_build_identity(self): + proof = {"run_id": 12, "run_attempt": 1, "source_sha": "a" * 40, + "build": "42", "pub_date": "2026-10-21T01:02:03Z", + "artifacts": {"macos": "maple-agent-prod-macos-aarch64-12"}} + original = p.durable_provenance(proof) + self.assertEqual(original, p.durable_provenance({**proof, "run_attempt": 2})) + self.assertNotEqual(original, p.durable_provenance({**proof, "source_sha": "b" * 40})) + self.assertNotIn("run_attempt", json.loads(original)) + + def test_stream_hash_reads_only_expected_size_plus_one_on_oversized_response(self): + stream = io.BytesIO(b"oversized response body") + with self.assertRaisesRegex(p.PublishError, "oversized"): + p.hash_stream(stream, maximum=3) + self.assertEqual(stream.tell(), 4) + + +class PinTests(unittest.TestCase): + def setUp(self): + self.key = base64.b64encode(b"Ed" + b"a" * 40).decode("ascii") + self.profile = {"prod": {"update_feed_url": p.FEED_URL, "update_public_keys": [self.key]}} + + def test_key_must_be_in_both_source_and_trusted_profiles(self): + p.validate_pins(self.key, self.profile, deepcopy(self.profile)) + for side in (0, 1): + for change in ([], [self.key + "x"]): + profiles = [deepcopy(self.profile), deepcopy(self.profile)] + profiles[side]["prod"]["update_public_keys"] = change + with self.subTest(side=side, change=change), self.assertRaises(p.PublishError): + p.validate_pins(self.key, *profiles) + + def test_feed_mismatch_and_missing_pins_fail(self): + for bad in ({"prod": {}}, {"prod": {"update_feed_url": "https://elsewhere.invalid", "update_public_keys": [self.key]}}): + with self.assertRaises(p.PublishError): + p.validate_pins(self.key, self.profile, bad) + + def test_source_profile_is_read_at_exact_source_sha(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "apps/maple-agent").mkdir(parents=True) + (root / "apps/maple-agent/release-profiles.json").write_bytes(p.encode_json(self.profile)) + key = root / "public-key.pub" + key.write_text("untrusted comment: test\n" + self.key + "\n") + response = {"encoding": "base64", "content": base64.b64encode(p.encode_json(self.profile)).decode()} + with patch.object(p, "ROOT", root), patch.object(p, "gh_json", return_value=response) as api: + self.assertEqual(p.check_source_pins(key, "b" * 40), self.key) + self.assertTrue(api.call_args.args[0].endswith("?ref=" + "b" * 40)) + + def test_public_key_format_is_bounded_and_algorithm_checked(self): + with tempfile.TemporaryDirectory() as temporary: + key = Path(temporary) / "key.pub" + key.write_text(self.key + "\n") + self.assertEqual(p.public_key_text(key), self.key) + for value in ("invalid", self.key + "\n" + self.key, base64.b64encode(b"ED" + b"a" * 40).decode(), "x" * (p.MAX_SIGNATURE + 1)): + key.write_text(value) + with self.subTest(value=value[:20]), self.assertRaises(p.PublishError): + p.public_key_text(key) + + def test_existing_pointer_accepts_primary_when_new_release_selects_backup(self): + backup = base64.b64encode(b"Ed" + b"b" * 40).decode("ascii") + profile = deepcopy(self.profile) + profile["prod"]["update_public_keys"].append(backup) + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + (root / "apps/maple-agent").mkdir(parents=True) + (root / "apps/maple-agent/release-profiles.json").write_bytes(p.encode_json(profile)) + selected = root / "selected.pub" + selected.write_text(backup + "\n") + attempted = [] + + def verify(data, path): + candidate = p.public_key_text(path) + attempted.append(candidate) + if candidate == self.key: + return {"version": "1.0.0"} + raise p.PublishError("fixture signature mismatch") + + with patch.object(p, "ROOT", root), patch.object(p, "minisign_verify", side_effect=verify): + self.assertEqual(p.verify_current(b"signed-by-primary", selected), {"version": "1.0.0"}) + self.assertEqual(attempted, [self.key]) + profile["prod"]["update_public_keys"] = [backup] + (root / "apps/maple-agent/release-profiles.json").write_bytes(p.encode_json(profile)) + with patch.object(p, "ROOT", root), patch.object(p, "minisign_verify", side_effect=verify), self.assertRaisesRegex(p.PublishError, "not trusted"): + p.verify_current(b"signed-by-retired-primary", selected) + + +if __name__ == "__main__": + unittest.main() diff --git a/services/agent-updates/src/dry-run.ts b/services/agent-updates/src/dry-run.ts new file mode 100644 index 000000000..433d8cfa2 --- /dev/null +++ b/services/agent-updates/src/dry-run.ts @@ -0,0 +1,63 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +// Keep the checked-in production config undeployable before owner setup, while +// still bundling and validating code using Wrangler's actual dry-run path. +// This checked-in config uses whole-line comments and trailing commas only. +// Unsupported JSONC syntax fails closed instead of weakening deployment guards. +const config: unknown = JSON.parse( + (await Bun.file("wrangler.jsonc").text()) + .replace(/^\s*\/\/.*$/gm, "") + .replace(/,(\s*[}\]])/g, "$1"), +); +if (!config || typeof config !== "object" || Array.isArray(config)) { + throw new Error("invalid Worker configuration"); +} +const workerConfig = config as Record; +if ( + "route" in workerConfig || + "routes" in workerConfig || + "env" in workerConfig +) { + throw new Error( + "Agent dry run cannot manage routing or environment overrides", + ); +} +const directory = await mkdtemp(join(tmpdir(), "agent-updates-dry-run-")); +try { + const path = join(directory, "wrangler.json"); + await Bun.write( + path, + JSON.stringify({ + ...workerConfig, + main: resolve("src/index.ts"), + r2_buckets: [ + { + binding: "AGENT_RELEASES", + bucket_name: "agent-updates-local-dry-run", + }, + ], + }), + ); + const result = Bun.spawn( + [ + "node", + resolve("node_modules/wrangler/bin/wrangler.js"), + "deploy", + "--dry-run", + "--config", + path, + "--outdir", + resolve(".wrangler/dry-run"), + ], + { + stdout: "inherit", + stderr: "inherit", + env: { ...process.env, WRANGLER_SEND_METRICS: "false" }, + }, + ); + if ((await result.exited) !== 0) process.exitCode = 1; +} finally { + await rm(directory, { recursive: true, force: true }); +} diff --git a/services/agent-updates/src/fixtures.ts b/services/agent-updates/src/fixtures.ts new file mode 100644 index 000000000..3c2fdcbc9 --- /dev/null +++ b/services/agent-updates/src/fixtures.ts @@ -0,0 +1,39 @@ +import type { Manifest } from "./manifest"; + +export const PAYLOAD = new TextEncoder().encode("a signed package fixture\n"); +export function fixture(linux = false): { + manifest: Manifest; + bytes: Uint8Array; +} { + const prefix = "/agent/stable/releases/0.0.2/"; + const artifact = (name: string) => ({ + path: prefix + name, + size: PAYLOAD.length, + sha256: "a".repeat(64), + }); + const manifest: Manifest = { + format: "maple-agent-update-v1", + product: "maple-agent", + channel: "stable", + version: "0.0.2", + build: "2", + source_sha: "b".repeat(40), + pub_date: "2026-10-21T12:00:00Z", + notes: "Preview update\nFixes.", + artifacts: { "darwin-aarch64": artifact("Maple-Agent.app.tar.gz") }, + downloads: { macos: artifact("Maple-Agent.dmg") }, + }; + if (linux) + manifest.artifacts["linux-x86_64"] = manifest.downloads["linux-appimage"] = + artifact("Maple-Agent.AppImage"); + return { manifest, bytes: encodeFixture(manifest) }; +} +export function encodeFixture(manifest: Manifest): Uint8Array { + const signature = btoa("ED" + "\0".repeat(72)); + return new TextEncoder().encode( + JSON.stringify({ + manifest: JSON.stringify(manifest), + signature: `untrusted comment: fixture\n${signature}\ntrusted comment: product=maple-agent channel=stable version=${manifest.version}\n${btoa("\0".repeat(64))}\n`, + }), + ); +} diff --git a/services/agent-updates/src/index.ts b/services/agent-updates/src/index.ts new file mode 100644 index 000000000..737a7b980 --- /dev/null +++ b/services/agent-updates/src/index.ts @@ -0,0 +1,2 @@ +// Validation helpers are not workerd service entrypoints. +export { default } from "./worker"; diff --git a/services/agent-updates/src/manifest.ts b/services/agent-updates/src/manifest.ts new file mode 100644 index 000000000..e41376def --- /dev/null +++ b/services/agent-updates/src/manifest.ts @@ -0,0 +1,194 @@ +export const MAX_ENVELOPE_BYTES = 128 * 1024; +export const MAX_MANIFEST_BYTES = 64 * 1024; +export const MAX_SIGNATURE_BYTES = 4096; +export const MAX_ARTIFACT_BYTES = 4 * 1024 ** 3; +export const VERSION = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/; +const encoder = new TextEncoder(); + +export interface Artifact { + path: string; + size: number; + sha256: string; +} +export interface Manifest { + format: "maple-agent-update-v1"; + product: "maple-agent"; + channel: "stable"; + version: string; + build: string; + source_sha: string; + pub_date: string; + notes: string; + artifacts: { "darwin-aarch64": Artifact; "linux-x86_64"?: Artifact }; + downloads: { macos: Artifact; "linux-appimage"?: Artifact }; +} +export interface Envelope { + manifest: string; + signature: string; +} + +function record(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} +function keys(value: Record, allowed: string[]): boolean { + return Object.keys(value).every((key) => allowed.includes(key)); +} +function fullMatch(pattern: RegExp, value: string): boolean { + // JavaScript's `$` also matches before a final line terminator. + return pattern.exec(value)?.[0] === value; +} +function boundedText(value: unknown, max: number): value is string { + return typeof value === "string" && encoder.encode(value).length <= max; +} +function scalarText(value: string): boolean { + // JSON may contain escaped lone UTF-16 surrogates despite valid UTF-8 bytes. + for (const character of value) { + const code = character.codePointAt(0)!; + if (code >= 0xd800 && code <= 0xdfff) return false; + } + return true; +} +function artifact( + value: unknown, + version: string, + suffix: string, +): value is Artifact { + if (!record(value) || !keys(value, ["path", "size", "sha256"])) return false; + const prefix = `/agent/stable/releases/${version}/`; + if (typeof value.path !== "string" || !value.path.startsWith(prefix)) + return false; + const file = value.path.slice(prefix.length); + return ( + file.length <= 200 && + fullMatch(/^[A-Za-z0-9][A-Za-z0-9._-]*$/, file) && + file.endsWith(suffix) && + !file.includes("..") && + typeof value.size === "number" && + Number.isSafeInteger(value.size) && + value.size > 0 && + value.size <= MAX_ARTIFACT_BYTES && + typeof value.sha256 === "string" && + fullMatch(/^[0-9a-f]{64}$/, value.sha256) + ); +} + +export function parseManifest(value: unknown): Manifest { + if ( + !record(value) || + !keys(value, [ + "format", + "product", + "channel", + "version", + "build", + "source_sha", + "pub_date", + "notes", + "artifacts", + "downloads", + ]) || + value.format !== "maple-agent-update-v1" || + value.product !== "maple-agent" || + value.channel !== "stable" || + typeof value.version !== "string" || + value.version.length > 64 || + !fullMatch(VERSION, value.version) || + typeof value.build !== "string" || + !fullMatch(/^[1-9][0-9]{0,17}$/, value.build) || + typeof value.source_sha !== "string" || + !fullMatch(/^[0-9a-f]{40}$/, value.source_sha) || + typeof value.pub_date !== "string" || + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/.test(value.pub_date) || + !Number.isFinite(Date.parse(value.pub_date)) || + new Date(value.pub_date).toISOString().replace(".000Z", "Z") !== + value.pub_date || + !boundedText(value.notes, 16 * 1024) || + !scalarText(value.notes) || + /[\x00-\x08\x0b-\x1f\x7f]/.test(value.notes) || + !record(value.artifacts) || + !keys(value.artifacts, ["darwin-aarch64", "linux-x86_64"]) || + !record(value.downloads) || + !keys(value.downloads, ["macos", "linux-appimage"]) || + !artifact( + value.artifacts["darwin-aarch64"], + value.version, + ".app.tar.gz", + ) || + !artifact(value.downloads.macos, value.version, ".dmg") + ) + throw new Error("invalid manifest"); + const linux = value.artifacts["linux-x86_64"]; + const download = value.downloads["linux-appimage"]; + if ( + (linux === undefined) !== (download === undefined) || + (linux !== undefined && + (!artifact(linux, value.version, ".AppImage") || + !artifact(download, value.version, ".AppImage") || + linux.path !== download.path || + linux.size !== download.size || + linux.sha256 !== download.sha256)) + ) { + throw new Error("invalid Linux artifact"); + } + return value as unknown as Manifest; +} + +// The Worker validates public metadata structure. Clients and the publisher +// authenticate exact bytes with pinned keys; R2 write authority is not signing authority. +export function parseEnvelope(bytes: Uint8Array): { + envelope: Envelope; + manifest: Manifest; +} { + if (bytes.length === 0 || bytes.length > MAX_ENVELOPE_BYTES) + throw new Error("oversized envelope"); + const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes); + const envelope: unknown = JSON.parse(text); + if ( + !record(envelope) || + !keys(envelope, ["manifest", "signature"]) || + !boundedText(envelope.manifest, MAX_MANIFEST_BYTES) || + !boundedText(envelope.signature, MAX_SIGNATURE_BYTES) + ) + throw new Error("invalid envelope"); + const manifest = parseManifest(JSON.parse(envelope.manifest)); + const lines = envelope.signature.trimEnd().split("\n"); + if ( + lines.length !== 4 || + !lines[0].startsWith("untrusted comment: ") || + lines[2] !== + `trusted comment: product=maple-agent channel=stable version=${manifest.version}` || + !fullMatch(/^[A-Za-z0-9+/]+={0,2}$/, lines[1]) || + !fullMatch(/^[A-Za-z0-9+/]+={0,2}$/, lines[3]) + ) + throw new Error("invalid signature format"); + const signature = atob(lines[1]); + if ( + signature.length !== 74 || + signature.slice(0, 2) !== "ED" || + atob(lines[3]).length !== 64 + ) + throw new Error("unsupported signature format"); + return { envelope: envelope as unknown as Envelope, manifest }; +} + +export function releasePath( + path: string, +): { version: string; file: string; key: string } | null { + const match = + /^\/agent\/stable\/releases\/([^/]+)\/([A-Za-z0-9][A-Za-z0-9._-]{0,199})$/.exec( + path, + ); + if ( + !match || + match[1].length > 64 || + !fullMatch(VERSION, match[1]) || + match[0] !== path || + match[2].includes("..") + ) + return null; + return { + version: match[1], + file: match[2], + key: `stable/releases/${match[1]}/${match[2]}`, + }; +} diff --git a/services/agent-updates/src/runtime.test.ts b/services/agent-updates/src/runtime.test.ts new file mode 100644 index 000000000..6e638cda4 --- /dev/null +++ b/services/agent-updates/src/runtime.test.ts @@ -0,0 +1,125 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "bun:test"; +import { fixture, PAYLOAD } from "./fixtures"; + +// Miniflare is Wrangler's exact locked runtime dependency. Node hosts the +// harness; assertions make real requests through workerd and its R2 binding. +const server = ` + import { Miniflare, convertV4MiniflareOptions } from 'miniflare'; + import { readFileSync } from 'node:fs'; + const mf = new Miniflare(convertV4MiniflareOptions({modules:true, name:'agent-updates-test', script:readFileSync(process.env.MAPLE_TEST_WORKER,'utf8'), compatibilityDate:'2026-08-24', r2Buckets:['AGENT_RELEASES'], port:0, inspectorPort:0})); + process.on('SIGTERM', async()=>{await mf.dispose(); process.exit(0);}); + process.send({origin:(await mf.ready).origin}); +`; +test("real workerd R2 binding streams exact immutable bytes, ranges, HEAD and redirects", async () => { + const directory = await mkdtemp(join(tmpdir(), "agent-updates-workerd-")); + let child: ReturnType | undefined; + let timer: ReturnType | undefined; + try { + const wrapper = join(directory, "entry.ts"); + await Bun.write( + wrapper, + ` + import worker from ${JSON.stringify(join(import.meta.dir, "index.ts"))}; + export default { async fetch(request,env) { + if (new URL(request.url).pathname === '/__seed' && request.method === 'POST') { + const {metadata,payload} = await request.json(); + const bytes = Uint8Array.from(metadata); + await env.AGENT_RELEASES.put('stable/latest.json',bytes); + await env.AGENT_RELEASES.put('stable/releases/0.0.2/manifest.json',bytes); + await env.AGENT_RELEASES.put('stable/releases/0.0.2/Maple-Agent.app.tar.gz',Uint8Array.from(payload)); + await env.AGENT_RELEASES.put('stable/releases/0.0.2/Maple-Agent.dmg',Uint8Array.from(payload)); + return new Response('seeded'); + } + return worker.fetch(request,env); + }}; + `, + ); + const build = await Bun.build({ + entrypoints: [wrapper], + outdir: directory, + target: "browser", + format: "esm", + }); + if (!build.success) throw new Error("fixture Worker bundle failed"); + const origin = await new Promise((resolve, reject) => { + timer = setTimeout( + () => reject(new Error("workerd startup timeout")), + 20000, + ); + child = Bun.spawn(["node", "--input-type=module", "-e", server], { + env: { + ...process.env, + MAPLE_TEST_WORKER: join(directory, "entry.js"), + MAPLE_TEST_METADATA: Buffer.from(fixture().bytes).toString("base64"), + MAPLE_TEST_PAYLOAD: Buffer.from(PAYLOAD).toString("base64"), + }, + stdout: "ignore", + stderr: "inherit", + serialization: "json", + ipc(value: unknown) { + if ( + value && + typeof value === "object" && + "origin" in value && + typeof value.origin === "string" + ) + resolve(value.origin); + }, + onExit(_child, code) { + reject(new Error(`workerd exited ${code}`)); + }, + }); + }); + clearTimeout(timer); + const seeded = await fetch(`${origin}/__seed`, { + method: "POST", + body: JSON.stringify({ + metadata: Array.from(fixture().bytes), + payload: Array.from(PAYLOAD), + }), + }); + expect(seeded.status).toBe(200); + const latest = await fetch(`${origin}/agent/stable/latest.json`); + expect(latest.status).toBe(200); + expect(new Uint8Array(await latest.arrayBuffer())).toEqual(fixture().bytes); + const redirect = await fetch(`${origin}/download/agent/stable/macos`, { + redirect: "manual", + }); + expect(redirect.status).toBe(302); + expect(redirect.headers.get("location")).toBe( + "/agent/stable/releases/0.0.2/Maple-Agent.dmg", + ); + const artifact = `${origin}/agent/stable/releases/0.0.2/Maple-Agent.app.tar.gz`; + const full = await fetch(artifact); + expect(full.status).toBe(200); + expect(new Uint8Array(await full.arrayBuffer())).toEqual(PAYLOAD); + const range = await fetch(artifact, { headers: { range: "bytes=2-7" } }); + expect(range.status).toBe(206); + expect(range.headers.get("content-range")).toBe( + `bytes 2-7/${PAYLOAD.length}`, + ); + expect(new Uint8Array(await range.arrayBuffer())).toEqual( + PAYLOAD.slice(2, 8), + ); + const head = await fetch(artifact, { method: "HEAD" }); + expect(head.status).toBe(200); + expect(head.headers.get("content-length")).toBe(String(PAYLOAD.length)); + expect(await head.text()).toBe(""); + expect( + (await fetch(`${origin}/download/agent/stable/linux-appimage`)).status, + ).toBe(404); + expect((await fetch(`${origin}/latest.json`)).status).toBe(404); + } finally { + clearTimeout(timer); + child?.kill("SIGTERM"); + if (child) { + const forceStop = setTimeout(() => child?.kill("SIGKILL"), 3000); + await child.exited; + clearTimeout(forceStop); + } + await rm(directory, { recursive: true, force: true }); + } +}, 45000); diff --git a/services/agent-updates/src/worker.test.ts b/services/agent-updates/src/worker.test.ts new file mode 100644 index 000000000..682a09867 --- /dev/null +++ b/services/agent-updates/src/worker.test.ts @@ -0,0 +1,332 @@ +import { expect, test } from "bun:test"; +import worker, { type Bucket } from "./worker"; +import { fixture, encodeFixture, PAYLOAD } from "./fixtures"; +import { + MAX_ENVELOPE_BYTES, + parseEnvelope, + parseManifest, + releasePath, +} from "./manifest"; + +class MemoryBucket implements Bucket { + objects = new Map>(); + calls: string[] = []; + fail = false; + async get( + key: string, + options?: { range: { offset: number; length: number } }, + ) { + this.calls.push(`get:${key}`); + if (this.fail) throw new Error("private fixture error"); + const bytes = this.objects.get(key); + if (!bytes) return null; + const range = options?.range; + const body = range + ? bytes.slice(range.offset, range.offset + range.length) + : bytes; + return { + size: bytes.length, + httpEtag: '"fixture"', + body: new Response(body).body!, + }; + } + async head(key: string) { + this.calls.push(`head:${key}`); + if (this.fail) throw new Error("private fixture error"); + const bytes = this.objects.get(key); + return bytes ? { size: bytes.length, httpEtag: '"fixture"' } : null; + } +} +function setup(linux = false) { + const bucket = new MemoryBucket(); + const current = fixture(linux); + bucket.objects.set("stable/latest.json", current.bytes); + bucket.objects.set("stable/releases/0.0.2/manifest.json", current.bytes); + for (const artifact of [ + ...Object.values(current.manifest.artifacts), + ...Object.values(current.manifest.downloads), + ]) + bucket.objects.set(artifact.path.replace("/agent/", ""), PAYLOAD); + const fetch = (path: string, init?: RequestInit) => + worker.fetch(new Request(`https://updates.example${path}`, init), { + AGENT_RELEASES: bucket, + }); + return { bucket, fetch, ...current }; +} + +test("latest exact envelope and HEAD are never cached", async () => { + const { fetch, bytes } = setup(); + for (const method of ["GET", "HEAD"]) { + const result = await fetch("/agent/stable/latest.json", { method }); + expect(result.status).toBe(200); + expect(result.headers.get("cache-control")).toBe("no-store"); + expect(result.headers.get("cdn-cache-control")).toBe("no-store"); + expect(result.headers.get("content-length")).toBe(String(bytes.length)); + expect(new Uint8Array(await result.arrayBuffer())).toEqual( + method === "HEAD" ? new Uint8Array() : bytes, + ); + } +}); +test("downloads are uncached origin-relative redirects; omitted Linux is isolated", async () => { + for (const linux of [false, true]) { + const { fetch } = setup(linux); + for (const method of ["GET", "HEAD"]) { + const mac = await fetch("/download/agent/stable/macos", { method }); + expect(mac.status).toBe(302); + expect(mac.headers.get("location")).toBe( + "/agent/stable/releases/0.0.2/Maple-Agent.dmg", + ); + expect(mac.headers.get("cache-control")).toBe("no-store"); + expect( + (await fetch("/download/agent/stable/linux-appimage", { method })) + .status, + ).toBe(linux ? 302 : 404); + } + } +}); +test("immutable artifact GET, HEAD, ranges, validators and If-Range", async () => { + const { fetch, bucket } = setup(); + const path = "/agent/stable/releases/0.0.2/Maple-Agent.app.tar.gz"; + const result = await fetch(path); + expect(await result.text()).toBe(new TextDecoder().decode(PAYLOAD)); + expect(result.headers.get("cache-control")).toContain("immutable"); + for (const [range, expected] of [ + ["bytes=2-6", PAYLOAD.slice(2, 7)], + ["bytes=2-", PAYLOAD.slice(2)], + ["bytes=-4", PAYLOAD.slice(-4)], + ["bytes=0-9999", PAYLOAD], + ]) { + const result = await fetch(path, { headers: { range: range as string } }); + expect(result.status).toBe(206); + expect(new Uint8Array(await result.arrayBuffer())).toEqual( + expected as Uint8Array, + ); + } + for (const range of [ + "bytes=9999-", + "bytes=-0", + "bytes=3-2", + "bytes=0-1,4-5", + "bytes=-", + "items=0-1", + "bytes=999999999999999999999-9999999999999999999999", + ]) + expect((await fetch(path, { headers: { range } })).status).toBe(416); + expect( + ( + await fetch(path, { + headers: { range: "bytes=0-2", "if-range": '"changed"' }, + }) + ).status, + ).toBe(200); + expect( + (await fetch(path, { headers: { "if-none-match": '"fixture"' } })).status, + ).toBe(304); + bucket.calls = []; + const head = await fetch(path, { + method: "HEAD", + headers: { range: "bytes=0-2" }, + }); + expect(head.status).toBe(200); + expect(head.headers.get("content-length")).toBe(String(PAYLOAD.length)); + expect(head.headers.get("content-range")).toBeNull(); + expect(await head.text()).toBe(""); + expect(bucket.calls).not.toContain( + "get:stable/releases/0.0.2/Maple-Agent.app.tar.gz", + ); +}); +test("immutable manifest is exact, but unsigned auxiliary objects are private", async () => { + const { fetch, bucket, bytes } = setup(); + const response = await fetch("/agent/stable/releases/0.0.2/manifest.json"); + expect(response.status).toBe(200); + expect(new Uint8Array(await response.arrayBuffer())).toEqual(bytes); + for (const file of [ + "private.json", + "publication-manifest.json", + "publication-metadata.json", + "metadata/macos-build-info.json", + ]) { + bucket.objects.set(`stable/releases/0.0.2/${file}`, PAYLOAD); + expect((await fetch(`/agent/stable/releases/0.0.2/${file}`)).status).toBe( + 404, + ); + } +}); +test("unknown paths, methods and ambiguous encodings cannot reach arbitrary bucket keys", async () => { + const { fetch, bucket } = setup(); + for (const path of [ + "/latest.json", + "/download/research/stable/macos", + "/agent/stable/random", + "/agent/stable/latest.json?secret=value", + "/agent/stable/releases/0.0.2/x%2fy.dmg", + "/agent/stable/releases/00.0.2/a.dmg", + "/agent/stable//latest.json", + "/agent/stable/releases/0.0.2/a..dmg", + "/download/agent/stable/unknown", + ]) + expect((await fetch(path)).status).toBe(404); + expect(bucket.calls).toEqual([]); + const post = await fetch("/agent/stable/latest.json", { method: "POST" }); + expect(post.status).toBe(405); + expect(post.headers.get("allow")).toBe("GET, HEAD"); +}); +test("missing and malformed data and R2 failures fail only Agent with bounded errors", async () => { + const { fetch, bucket } = setup(); + bucket.objects.delete("stable/latest.json"); + expect((await fetch("/agent/stable/latest.json")).status).toBe(404); + for (const bad of [ + new Uint8Array(MAX_ENVELOPE_BYTES + 1), + new TextEncoder().encode("{}"), + new Uint8Array([0xff]), + ]) { + bucket.objects.set("stable/latest.json", bad); + for (const path of [ + "/agent/stable/latest.json", + "/download/agent/stable/macos", + ]) { + const result = await fetch(path); + expect(result.status).toBe(503); + expect(await result.text()).toBe("503\n"); + } + } + bucket.fail = true; + expect((await fetch("/agent/stable/latest.json")).status).toBe(503); + expect((await fetch("/latest.json")).status).toBe(404); +}); +test("size mismatch and incomplete publication cannot stream corrupt or partial artifacts", async () => { + const { fetch, bucket } = setup(); + const path = "/agent/stable/releases/0.0.2/Maple-Agent.dmg"; + bucket.objects.set( + "stable/releases/0.0.2/Maple-Agent.dmg", + new Uint8Array([1]), + ); + expect((await fetch(path)).status).toBe(503); + bucket.objects.delete("stable/releases/0.0.2/manifest.json"); + expect((await fetch(path)).status).toBe(404); +}); +test("metadata stream size is enforced independently of the bucket's declared size", async () => { + for (const oversized of [false, true]) { + const { fetch, bucket, bytes } = setup(); + let cancelled = false; + const stream = oversized + ? new ReadableStream>({ + start(controller) { + controller.enqueue(new Uint8Array(MAX_ENVELOPE_BYTES + 1)); + }, + cancel() { + cancelled = true; + }, + }) + : new Response(bytes).body!; + bucket.get = async () => ({ + size: oversized ? MAX_ENVELOPE_BYTES : bytes.length + 1, + httpEtag: '"fixture"', + body: stream, + }); + const result = await fetch("/agent/stable/latest.json"); + expect(result.status).toBe(503); + if (oversized) expect(cancelled).toBe(true); + } +}); +test("schema binds product, channel, version, target, path, size, hashes and signature comment", () => { + const mutations = [ + (v: any) => { + v.product = "research"; + }, + (v: any) => { + v.channel = "dev"; + }, + (v: any) => { + v.version = "0.0.2-beta"; + }, + (v: any) => { + v.build = "01"; + }, + (v: any) => { + v.build = "2\n"; + }, + (v: any) => { + v.source_sha += "\n"; + }, + (v: any) => { + v.downloads.macos.sha256 += "\n"; + }, + (v: any) => { + v.version += "\n"; + }, + (v: any) => { + v.pub_date = "2026-02-30T00:00:00Z"; + }, + (v: any) => { + v.notes = "\u0000"; + }, + (v: any) => { + v.notes = "\ud800"; + }, + (v: any) => { + v.notes = "é".repeat(8193); + }, + (v: any) => { + v.extra = true; + }, + (v: any) => { + v.artifacts["windows-x86_64"] = v.artifacts["darwin-aarch64"]; + }, + (v: any) => { + delete v.artifacts["darwin-aarch64"]; + }, + (v: any) => { + v.downloads.macos.path = "https://evil.example/a.dmg"; + }, + (v: any) => { + v.downloads.macos.path = "/agent/stable/releases/0.0.1/a.dmg"; + }, + (v: any) => { + v.downloads.macos.path += "?x=y"; + }, + (v: any) => { + v.downloads.macos.path = "/agent/stable/releases/0.0.2/../a.dmg"; + }, + (v: any) => { + v.downloads.macos.path = "/agent/stable/releases/0.0.2/a%2fb.dmg"; + }, + (v: any) => { + v.downloads.macos.size = 4 * 1024 ** 3 + 1; + }, + (v: any) => { + v.downloads.macos.sha256 = "A".repeat(64); + }, + (v: any) => { + v.artifacts["linux-x86_64"] = v.downloads.macos; + }, + ]; + for (const change of mutations) { + const { manifest } = fixture(); + change(manifest); + expect(() => parseEnvelope(encodeFixture(manifest))).toThrow(); + } + const envelope = JSON.parse(new TextDecoder().decode(fixture().bytes)); + envelope.signature = envelope.signature.replace( + "version=0.0.2", + "version=0.0.3", + ); + expect(() => + parseEnvelope(new TextEncoder().encode(JSON.stringify(envelope))), + ).toThrow(); + expect(releasePath("/agent/stable/releases/0.0.2/manifest.json")?.key).toBe( + "stable/releases/0.0.2/manifest.json", + ); + expect( + releasePath("/agent/stable/releases/0.0.2\n/manifest.json"), + ).toBeNull(); + const noncanonical = fixture().manifest; + noncanonical.version += "\n"; + for (const artifact of [ + ...Object.values(noncanonical.artifacts), + ...Object.values(noncanonical.downloads), + ]) { + artifact.path = artifact.path.replace("/0.0.2/", "/0.0.2\n/"); + } + expect(() => parseManifest(noncanonical)).toThrow(); +}); diff --git a/services/agent-updates/src/worker.ts b/services/agent-updates/src/worker.ts new file mode 100644 index 000000000..a899f838e --- /dev/null +++ b/services/agent-updates/src/worker.ts @@ -0,0 +1,261 @@ +import { + MAX_ENVELOPE_BYTES, + parseEnvelope, + releasePath, + type Artifact, + type Manifest, +} from "./manifest"; + +interface StoredObject { + size: number; + httpEtag: string; + body?: ReadableStream; +} +export interface Bucket { + get( + key: string, + options?: { range: { offset: number; length: number } }, + ): Promise; + head(key: string): Promise; +} +export interface Env { + AGENT_RELEASES: Bucket; +} + +const NO_STORE = { + "cache-control": "no-store", + "cdn-cache-control": "no-store", + "x-content-type-options": "nosniff", +}; +function response( + status: number, + method: string, + extra?: HeadersInit, +): Response { + return new Response(method === "HEAD" ? null : `${status}\n`, { + status, + headers: { + ...NO_STORE, + "content-type": "text/plain; charset=utf-8", + ...extra, + }, + }); +} +async function metadata( + bucket: Bucket, + key: string, +): Promise<{ bytes: Uint8Array; manifest: Manifest } | null> { + const object = await bucket.get(key); + if (!object) return null; + if (object.size === 0 || object.size > MAX_ENVELOPE_BYTES || !object.body) { + await object.body?.cancel().catch(() => {}); + throw new Error("metadata size"); + } + const reader = object.body.getReader(); + let length = 0; + const chunks: Uint8Array[] = []; + try { + while (true) { + const part = await reader.read(); + if (part.done) break; + length += part.value.length; + if (length > MAX_ENVELOPE_BYTES || length > object.size) + throw new Error("metadata size"); + chunks.push(part.value); + } + } catch (error) { + await reader.cancel().catch(() => {}); + throw error; + } + if (length !== object.size) throw new Error("truncated metadata"); + const bytes = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.length; + } + return { bytes, manifest: parseEnvelope(bytes).manifest }; +} + +function requestedRange( + header: string, + size: number, +): { offset: number; length: number } | null { + const match = /^bytes=(\d*)-(\d*)$/.exec(header); + if (!match || (!match[1] && !match[2])) return null; + if (!match[1]) { + const suffix = Number(match[2]); + return Number.isSafeInteger(suffix) && suffix > 0 + ? { offset: Math.max(0, size - suffix), length: Math.min(size, suffix) } + : null; + } + const start = Number(match[1]); + const end = match[2] ? Number(match[2]) : size - 1; + if ( + !Number.isSafeInteger(start) || + !Number.isSafeInteger(end) || + start >= size || + end < start + ) + return null; + return { offset: start, length: Math.min(end, size - 1) - start + 1 }; +} + +function contentType(path: string): string { + if (path.endsWith(".dmg")) return "application/x-apple-diskimage"; + if (path.endsWith(".tar.gz")) return "application/gzip"; + return "application/octet-stream"; +} +async function artifactResponse( + request: Request, + bucket: Bucket, + key: string, + artifact: Artifact, +): Promise { + const object = await bucket.head(key); + if (!object) return response(404, request.method); + if (object.size !== artifact.size) throw new Error("artifact size mismatch"); + const headers = new Headers({ + "cache-control": "public, max-age=31536000, immutable", + "content-type": contentType(artifact.path), + "x-content-type-options": "nosniff", + "accept-ranges": "bytes", + etag: object.httpEtag, + "content-length": String(object.size), + }); + if ( + request.headers + .get("if-none-match") + ?.split(",") + .some((value) => value.trim() === object.httpEtag || value.trim() === "*") + ) + return new Response(null, { status: 304, headers }); + // RFC 9110: Range modifies GET only; HEAD describes the full representation. + const rangeHeader = + request.method === "GET" ? request.headers.get("range") : null; + const ifRange = request.headers.get("if-range"); + const range = + rangeHeader && (!ifRange || ifRange === object.httpEtag) + ? requestedRange(rangeHeader, object.size) + : undefined; + if (range === null) + return response(416, request.method, { + "content-range": `bytes */${object.size}`, + "accept-ranges": "bytes", + }); + if (range) { + headers.set( + "content-range", + `bytes ${range.offset}-${range.offset + range.length - 1}/${object.size}`, + ); + headers.set("content-length", String(range.length)); + } + const status = range ? 206 : 200; + if (request.method === "HEAD") return new Response(null, { status, headers }); + const full = await bucket.get(key, range ? { range } : undefined); + if ( + !full || + !full.body || + full.size !== object.size || + full.httpEtag !== object.httpEtag + ) { + await full?.body?.cancel().catch(() => {}); + throw new Error("artifact changed"); + } + return new Response(full.body, { status, headers }); +} + +export default { + async fetch(request: Request, env: Env): Promise { + const url = new URL(request.url); + const path = url.pathname; + // No origin fetch: missing data and exceptions remain inside this Worker. + if ( + !path.startsWith("/agent/stable/") && + !path.startsWith("/download/agent/stable/") + ) + return response(404, request.method); + if (!["GET", "HEAD"].includes(request.method)) + return response(405, request.method, { allow: "GET, HEAD" }); + if ( + url.search || + path.includes("%") || + path.includes("\\") || + path.includes("//") + ) + return response(404, request.method); + try { + if ( + path === "/agent/stable/latest.json" || + path.startsWith("/download/agent/stable/") + ) { + if ( + path.startsWith("/download/") && + ![ + "/download/agent/stable/macos", + "/download/agent/stable/linux-appimage", + ].includes(path) + ) + return response(404, request.method); + const current = await metadata( + env.AGENT_RELEASES, + "stable/latest.json", + ); + if (!current) return response(404, request.method); + if (path.startsWith("/download/")) { + const target = path.endsWith("/macos") + ? current.manifest.downloads.macos + : current.manifest.downloads["linux-appimage"]; + return target + ? new Response(null, { + status: 302, + headers: { ...NO_STORE, location: target.path }, + }) + : response(404, request.method); + } + return new Response(request.method === "HEAD" ? null : current.bytes, { + headers: { + ...NO_STORE, + "content-type": "application/json; charset=utf-8", + "content-length": String(current.bytes.length), + }, + }); + } + const release = releasePath(path); + if (!release) return response(404, request.method); + const published = await metadata( + env.AGENT_RELEASES, + `stable/releases/${release.version}/manifest.json`, + ); + if (!published) return response(404, request.method); + if (published.manifest.version !== release.version) + throw new Error("version mismatch"); + if (release.file === "manifest.json") + return new Response( + request.method === "HEAD" ? null : published.bytes, + { + headers: { + "cache-control": "public, max-age=31536000, immutable", + "content-type": "application/json; charset=utf-8", + "content-length": String(published.bytes.length), + "x-content-type-options": "nosniff", + }, + }, + ); + const artifact = [ + ...Object.values(published.manifest.artifacts), + ...Object.values(published.manifest.downloads), + ].find((value) => value.path === path); + return artifact + ? await artifactResponse( + request, + env.AGENT_RELEASES, + release.key, + artifact, + ) + : response(404, request.method); + } catch { + return response(503, request.method); + } + }, +}; diff --git a/services/agent-updates/tsconfig.json b/services/agent-updates/tsconfig.json new file mode 100644 index 000000000..a8f94721f --- /dev/null +++ b/services/agent-updates/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "lib": ["ES2022", "WebWorker"], + "module": "ESNext", + "moduleResolution": "Bundler", + "noEmit": true, + "skipLibCheck": true, + "strict": true, + "target": "ES2022", + "types": ["bun"] + }, + "include": ["src/**/*.ts"] +} diff --git a/services/agent-updates/wrangler.jsonc b/services/agent-updates/wrangler.jsonc new file mode 100644 index 000000000..a94a0e9d4 --- /dev/null +++ b/services/agent-updates/wrangler.jsonc @@ -0,0 +1,16 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "maple-agent-updates", + "main": "src/index.ts", + "compatibility_date": "2026-08-24", + "workers_dev": false, + "preview_urls": false, + // Deliberately undeployable placeholder. Approve and configure the existing + // private bucket before deployment. Routes are owner-managed, never deployed. + "r2_buckets": [ + { + "binding": "AGENT_RELEASES", + "bucket_name": "REQUIRES-OWNER-APPROVED-AGENT-BUCKET", + }, + ], +} From a559225a56209364bd1d6d66322b677fbfd59f9c Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:42:00 +0000 Subject: [PATCH 03/12] feat(agent): verify and stage signed update packages --- apps/maple-agent/Cargo.lock | 120 +- apps/maple-agent/Cargo.toml | 4 +- .../crates/maple-agent-updater/Cargo.toml | 31 + .../crates/maple-agent-updater/src/client.rs | 582 ++++++ .../maple-agent-updater/src/installer.rs | 1764 +++++++++++++++++ .../crates/maple-agent-updater/src/lib.rs | 18 + .../maple-agent-updater/src/manifest.rs | 252 +++ .../crates/maple-agent-updater/src/state.rs | 297 +++ .../crates/maple-agent-updater/src/tests.rs | 731 +++++++ .../tests/fixtures/README.md | 8 + .../tests/fixtures/manifest.json | 1 + .../tests/fixtures/manifest.minisig | 4 + .../tests/fixtures/public-test-key.pub | 2 + .../tests/fixtures/signed-envelope.json | 4 + apps/maple-agent/flake.nix | 2 +- 15 files changed, 3807 insertions(+), 13 deletions(-) create mode 100644 apps/maple-agent/crates/maple-agent-updater/Cargo.toml create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/client.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/installer.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/lib.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/manifest.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/state.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/src/tests.rs create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/README.md create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/manifest.json create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/manifest.minisig create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/public-test-key.pub create mode 100644 apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-envelope.json diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index c76c9fa97..0f0e504d5 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -1169,6 +1169,15 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "blake3" version = "1.8.7" @@ -2388,6 +2397,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", + "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -2800,6 +2810,16 @@ dependencies = [ "spki 0.7.3", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0" @@ -2811,6 +2831,20 @@ dependencies = [ "signature 3.0.0", ] +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "ed25519-dalek" version = "3.0.0" @@ -2818,7 +2852,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ "curve25519-dalek 5.0.0", - "ed25519", + "ed25519 3.0.0", "rand_core 0.10.1", "serde", "sha2 0.11.0", @@ -3200,6 +3234,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -4816,7 +4860,7 @@ dependencies = [ "ctutils", "data-encoding", "derive_more", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "futures-util", "getrandom 0.4.3", "http", @@ -4862,7 +4906,7 @@ dependencies = [ "data-encoding", "data-encoding-macro", "derive_more", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "getrandom 0.4.3", "n0-error", "rand 0.10.2", @@ -5624,7 +5668,7 @@ dependencies = [ [[package]] name = "maple-agent" -version = "0.1.0" +version = "0.0.1" dependencies = [ "agent-client-protocol", "anyhow", @@ -5668,7 +5712,7 @@ dependencies = [ [[package]] name = "maple-agent-app" -version = "0.1.0" +version = "0.0.1" dependencies = [ "axum", "base64 0.22.1", @@ -5686,6 +5730,7 @@ dependencies = [ "libc", "log", "maple-agent", + "maple-agent-updater", "maple-billing", "maple-proxy", "maple-sdk", @@ -5719,9 +5764,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "maple-agent-updater" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64 0.22.1", + "blake2", + "ed25519-dalek 2.2.0", + "flate2", + "fs2", + "futures-util", + "hex", + "libc", + "minisign-verify", + "plist", + "reqwest 0.13.4", + "semver", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "thiserror 2.0.20", + "time", + "tokio", + "url", +] + [[package]] name = "maple-billing" -version = "0.1.0" +version = "0.0.1" dependencies = [ "reqwest 0.13.4", "serde", @@ -5913,6 +5986,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" +[[package]] +name = "minisign-verify" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -7470,7 +7549,7 @@ dependencies = [ [[package]] name = "pi-agent-core" -version = "0.1.0" +version = "0.0.1" dependencies = [ "async-trait", "futures-util", @@ -7483,7 +7562,7 @@ dependencies = [ [[package]] name = "pi-ai" -version = "0.1.0" +version = "0.0.1" dependencies = [ "async-trait", "bytes", @@ -7500,7 +7579,7 @@ dependencies = [ [[package]] name = "pi-coding-agent" -version = "0.1.0" +version = "0.0.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -10187,6 +10266,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "task-local" version = "0.1.1" @@ -10214,7 +10304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -12665,6 +12755,16 @@ dependencies = [ "time", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xcb" version = "1.7.1" diff --git a/apps/maple-agent/Cargo.toml b/apps/maple-agent/Cargo.toml index 3b43eb104..60ccc197c 100644 --- a/apps/maple-agent/Cargo.toml +++ b/apps/maple-agent/Cargo.toml @@ -1,10 +1,10 @@ [workspace] resolver = "2" -members = ["crates/maple-agent", "crates/maple-billing", "crates/pi-ai", "crates/pi-agent-core", "crates/pi-coding-agent", "app"] +members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-agent-updater", "crates/pi-ai", "crates/pi-agent-core", "crates/pi-coding-agent", "app"] [workspace.package] edition = "2024" -version = "0.1.0" +version = "0.0.1" license = "MIT" [workspace.dependencies] diff --git a/apps/maple-agent/crates/maple-agent-updater/Cargo.toml b/apps/maple-agent/crates/maple-agent-updater/Cargo.toml new file mode 100644 index 000000000..da02744b9 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "maple-agent-updater" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio = { workspace = true, features = ["fs"] } +reqwest.workspace = true +futures-util.workspace = true +minisign-verify = "=0.2.4" +semver = "1" +sha2 = "0.10" +hex = "0.4" +time = { version = "0.3", features = ["parsing"] } +url = { version = "2", features = ["serde"] } +fs2 = "0.4" +tempfile = "3" +libc = "0.2" +thiserror = "2" +tar = "0.4" +flate2 = "1" +plist = "1" + +[dev-dependencies] +base64 = "0.22" +blake2 = "0.10" +ed25519-dalek = "2" diff --git a/apps/maple-agent/crates/maple-agent-updater/src/client.rs b/apps/maple-agent/crates/maple-agent-updater/src/client.rs new file mode 100644 index 000000000..a5902e1fd --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/client.rs @@ -0,0 +1,582 @@ +use std::{ + path::{Path, PathBuf}, + time::Duration, +}; + +use anyhow::{Context, Result, bail, ensure}; +use fs2::FileExt; +use futures_util::StreamExt; +use reqwest::{StatusCode, header}; +use semver::Version; +use sha2::{Digest, Sha256}; +use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt}; +use url::Url; + +use crate::{ + manifest::{self, Artifact, Target, VerifiedRelease}, + state::{self, PreparedUpdate, StateStore}, +}; + +pub struct Config { + pub feed_url: Url, + pub public_keys: Vec, + pub current_version: Version, + pub target: Target, + pub installed_path: PathBuf, + pub state_dir: PathBuf, +} + +#[derive(Clone, Debug)] +pub enum CheckOutcome { + Current, + Available(Box), + Withdrawn, +} + +#[derive(Clone, Debug)] +pub enum Revalidation { + Ready(VerifiedRelease), + Superseded(VerifiedRelease), + Withdrawn, +} + +pub struct Updater { + config: Config, + client: reqwest::Client, + state: StateStore, + #[cfg(test)] + allow_loopback_http: bool, +} + +impl Updater { + pub fn new(config: Config) -> Result { + Self::construct(config, false) + } + + fn construct(config: Config, _test_http: bool) -> Result { + validate_url(&config.feed_url, _test_http)?; + ensure!( + !config.public_keys.is_empty() && config.public_keys.len() <= 4, + "updater requires pinned signing keys" + ); + for key in &config.public_keys { + if key.starts_with("untrusted comment:") { + minisign_verify::PublicKey::decode(key)?; + } else { + minisign_verify::PublicKey::from_base64(key)?; + } + } + manifest::stable_version(&config.current_version.to_string())?; + let state = StateStore::new(&config.state_dir, &config.installed_path)?; + let client = reqwest::Client::builder() + .user_agent("Maple-Agent-Updater/1") + .redirect(reqwest::redirect::Policy::none()) + .referer(false) + .no_proxy() + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(30 * 60)) + .build()?; + Ok(Self { + config, + client, + state, + #[cfg(test)] + allow_loopback_http: _test_http, + }) + } + + pub fn installed_path(&self) -> &Path { + self.state.installed_path() + } + + fn validate_url(&self, url: &Url) -> Result<()> { + #[cfg(test)] + let test_http = self.allow_loopback_http; + #[cfg(not(test))] + let test_http = false; + validate_url(url, test_http)?; + ensure!( + origin_allowed(&self.config.feed_url, url), + "update redirect is outside the compiled origin policy" + ); + Ok(()) + } + + async fn request(&self, url: Url, range_start: Option) -> Result { + let mut url = url; + for redirects in 0..=4 { + self.validate_url(&url)?; + let mut request = self + .client + .get(url.clone()) + .header(header::ACCEPT_ENCODING, "identity"); + if let Some(offset) = range_start { + request = request.header(header::RANGE, format!("bytes={offset}-")); + } + let response = request.send().await.context("update request failed")?; + if matches!(response.status().as_u16(), 301 | 302 | 303 | 307 | 308) { + ensure!(redirects < 4, "too many update redirects"); + let location = response + .headers() + .get(header::LOCATION) + .context("update redirect has no destination")? + .to_str()?; + ensure!(location.len() <= 2048, "update redirect is too long"); + url = url.join(location).context("invalid update redirect")?; + continue; + } + ensure!( + response + .headers() + .get(header::CONTENT_ENCODING) + .is_none_or(|value| value == "identity"), + "compressed update HTTP response is not allowed" + ); + return Ok(response); + } + bail!("too many update redirects") + } + + async fn discovery(&self) -> Result { + tokio::time::timeout(Duration::from_secs(60), async { + let response = self.request(self.config.feed_url.clone(), None).await?; + ensure!( + response.status() == StatusCode::OK, + "update metadata is unavailable" + ); + ensure!( + response + .content_length() + .is_none_or(|n| n <= manifest::MAX_ENVELOPE_BYTES as u64), + "update envelope is too large" + ); + let origin = response.url().clone(); + let mut bytes = Vec::new(); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.context("cannot read update metadata")?; + ensure!( + chunk.len() <= manifest::MAX_ENVELOPE_BYTES - bytes.len(), + "update envelope is too large" + ); + bytes.extend_from_slice(&chunk); + } + manifest::verify_envelope(&bytes, &origin, &self.config.public_keys) + }) + .await + .context("update metadata request timed out")? + } + + fn authenticate(&self, release: &VerifiedRelease) -> Result { + self.validate_url(&release.origin)?; + let verified = manifest::verify_envelope( + release.envelope.as_bytes(), + &release.origin, + &self.config.public_keys, + )?; + ensure!( + verified.manifest == release.manifest, + "release fields differ from its signed envelope" + ); + Ok(verified) + } + + fn eligible(&self, release: &VerifiedRelease) -> Result { + let version = release.version(); + Ok(version > self.config.current_version + && self + .state + .highest_verified()? + .as_ref() + .is_none_or(|highest| &version >= highest) + && release + .manifest + .artifacts + .contains_key(self.config.target.key())) + } + + pub async fn check(&self) -> Result { + let release = self.discovery().await?; + if !self.eligible(&release)? { + let saved = self.state.prepared()?; + let had_preparation = saved.is_some(); + if let Some(saved) = saved { + self.discard_prepared(Some(&saved.envelope)).await?; + } + return Ok(if had_preparation { + CheckOutcome::Withdrawn + } else { + CheckOutcome::Current + }); + } + self.state.advance(&release.version())?; + if let Some(saved) = self + .state + .prepared()? + .filter(|prepared| prepared.envelope != release.envelope) + { + self.discard_prepared(Some(&saved.envelope)).await?; + } + Ok(CheckOutcome::Available(Box::new(release))) + } + + pub async fn download(&self, release: &VerifiedRelease) -> Result { + self.download_with_progress(release, |_, _| {}).await + } + + pub async fn download_with_progress( + &self, + release: &VerifiedRelease, + mut progress: impl FnMut(u64, u64), + ) -> Result { + let release = self.authenticate(release)?; + ensure!(self.eligible(&release)?, "update is no longer eligible"); + self.state.advance(&release.version())?; + let artifact = release.artifact(self.config.target)?; + let lock_path = self + .state + .root + .join("payloads") + .join(format!("{}.lock", artifact.sha256)); + let lock = state::open_private(&lock_path, false)?; + FileExt::try_lock_exclusive(&lock).context("another process is downloading this update")?; + let payload_path = self.state.payload_path(&artifact.sha256, false); + if verify_payload(&payload_path, artifact).await.is_ok() { + self.state.save_prepared(&release)?; + return Ok(PreparedUpdate { + release, + payload_path, + installed_path: self.state.installed_path().to_owned(), + }); + } + let partial_path = self.state.payload_path(&artifact.sha256, true); + let file = state::open_private(&partial_path, false)?; + let mut file = tokio::fs::File::from_std(file); + let mut offset = file.metadata().await?.len(); + if offset > artifact.size { + file.set_len(0).await?; + offset = 0; + } + if offset < artifact.size { + let response = self + .request( + release.artifact_url(self.config.target)?, + (offset > 0).then_some(offset), + ) + .await?; + if response.status() == StatusCode::PARTIAL_CONTENT { + validate_range( + response + .headers() + .get(header::CONTENT_RANGE) + .context("missing resumed content range")? + .to_str()?, + offset, + artifact.size, + )?; + } else if response.status() == StatusCode::OK { + // A server ignoring Range is safe only when the partial bytes are + // discarded; never append a complete object to a partial object. + offset = 0; + file.set_len(0).await?; + } else { + bail!("update payload is unavailable"); + } + ensure!( + response + .content_length() + .is_none_or(|len| len == artifact.size - offset), + "update response length differs from signed size" + ); + file.seek(std::io::SeekFrom::Start(offset)).await?; + progress(offset, artifact.size); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = match chunk { + Ok(chunk) => chunk, + Err(error) => { + // Tokio may still have its last buffered write in + // flight. Finish it before a caller retries the range. + file.flush().await?; + file.sync_all().await?; + return Err(error).context("update download interrupted"); + } + }; + ensure!( + chunk.len() as u64 <= artifact.size - offset, + "update exceeds signed size" + ); + file.write_all(&chunk).await?; + offset += chunk.len() as u64; + progress(offset, artifact.size); + } + file.sync_all().await?; + } + drop(file); + if let Err(error) = verify_payload(&partial_path, artifact).await { + // A truncated transfer remains resumable. Complete corrupt bytes + // must be discarded, otherwise retries would repeatedly trust them. + if offset == artifact.size { + let _ = tokio::fs::remove_file(&partial_path).await; + } + return Err(error); + } + // Refuse a symlink destination rather than following it; rename itself + // replaces a directory entry and cannot write through a link. + if let Ok(metadata) = tokio::fs::symlink_metadata(&payload_path).await { + ensure!( + metadata.is_file() && !metadata.file_type().is_symlink(), + "unsafe prepared payload destination" + ); + } + tokio::fs::rename(&partial_path, &payload_path).await?; + std::fs::File::open(self.state.root.join("payloads"))?.sync_all()?; + self.state.save_prepared(&release)?; + Ok(PreparedUpdate { + release, + payload_path, + installed_path: self.state.installed_path().to_owned(), + }) + } + + pub async fn restore_prepared(&self) -> Result> { + let Some(saved) = self.state.prepared()? else { + return Ok(None); + }; + let result: Result> = async { + let origin: Url = saved.origin.parse()?; + self.validate_url(&origin)?; + let release = manifest::verify_envelope( + saved.envelope.as_bytes(), + &origin, + &self.config.public_keys, + )?; + if !self.eligible(&release)? { + self.discard_prepared(Some(&saved.envelope)).await?; + return Ok(None); + } + let artifact = release.artifact(self.config.target)?; + let payload_path = self.state.payload_path(&artifact.sha256, false); + verify_payload(&payload_path, artifact).await?; + Ok(Some(PreparedUpdate { + release, + payload_path, + installed_path: self.state.installed_path().to_owned(), + })) + } + .await; + match result { + Ok(prepared) => Ok(prepared), + Err(error) => { + self.discard_prepared(Some(&saved.envelope)).await?; + Err(error) + } + } + } + + pub async fn revalidate(&self, prepared: &PreparedUpdate) -> Result { + let authenticated = self.authenticate(&prepared.release)?; + ensure!( + prepared.installed_path == self.state.installed_path(), + "preparation belongs to another application" + ); + let artifact = authenticated.artifact(self.config.target)?; + ensure!( + prepared.payload_path == self.state.payload_path(&artifact.sha256, false), + "unexpected prepared payload path" + ); + verify_payload(&prepared.payload_path, artifact).await?; + // Do not clear a good preparation on network failure; users can retry. + let fresh = self.discovery().await?; + if fresh.envelope == authenticated.envelope && self.eligible(&fresh)? { + self.state.advance(&fresh.version())?; + return Ok(Revalidation::Ready(fresh)); + } + self.discard_prepared(Some(&authenticated.envelope)).await?; + if self.eligible(&fresh)? && fresh.version() > authenticated.version() { + self.state.advance(&fresh.version())?; + Ok(Revalidation::Superseded(fresh)) + } else { + Ok(Revalidation::Withdrawn) + } + } + + pub async fn clear_prepared(&self) -> Result<()> { + self.discard_prepared(None).await + } + + /// Call synchronously under the application-install and host-startup fences + /// immediately around final staged verification and replacement. No other + /// updater state method may be called by `operation` while this lock is held. + /// This closes the cross-process gap between fresh discovery and commit. + pub fn with_commit_guard( + &self, + prepared: &PreparedUpdate, + operation: impl FnOnce() -> std::result::Result, + ) -> Result { + let authenticated = self.authenticate(&prepared.release)?; + ensure!( + authenticated.version() > self.config.current_version, + "prepared update is not an upgrade" + ); + ensure!( + prepared.installed_path == self.state.installed_path(), + "preparation belongs to another application" + ); + ensure!( + prepared.payload_path + == self + .state + .payload_path(&authenticated.artifact(self.config.target)?.sha256, false), + "unexpected prepared payload path" + ); + self.state + .with_commit_guard(&authenticated.version(), &authenticated.envelope, || { + operation().map_err(anyhow::Error::msg) + }) + } + + async fn discard_prepared(&self, expected_envelope: Option<&str>) -> Result<()> { + let saved = self.state.take_prepared(expected_envelope)?; + if let Some(saved) = saved { + // Saved paths and ready flags never authorize filesystem changes. + // Only derive a filename from an authenticated artifact digest. + let Ok(release) = manifest::verify_envelope( + saved.envelope.as_bytes(), + &self.config.feed_url, + &self.config.public_keys, + ) else { + return Ok(()); + }; + let Ok(artifact) = release.artifact(self.config.target) else { + return Ok(()); + }; + let lock_path = self + .state + .root + .join("payloads") + .join(format!("{}.lock", artifact.sha256)); + let lock = state::open_private(&lock_path, false)?; + if FileExt::try_lock_exclusive(&lock).is_err() { + return Ok(()); + } + if self.state.prepared()?.as_ref().is_some_and(|current| { + manifest::verify_envelope( + current.envelope.as_bytes(), + &self.config.feed_url, + &self.config.public_keys, + ) + .ok() + .and_then(|release| { + release + .artifact(self.config.target) + .ok() + .map(|artifact| artifact.sha256.clone()) + }) + .is_some_and(|digest| digest == artifact.sha256) + }) { + return Ok(()); + } + let path = self.state.payload_path(&artifact.sha256, false); + match tokio::fs::remove_file(path).await { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + } + Ok(()) + } +} + +fn validate_url(url: &Url, _test_http: bool) -> Result<()> { + #[cfg(test)] + let secure = url.scheme() == "https" + || (_test_http && url.scheme() == "http" && url.host_str() == Some("127.0.0.1")); + #[cfg(not(test))] + let secure = url.scheme() == "https"; + ensure!( + secure && url.host_str().is_some(), + "update URL must use HTTPS" + ); + ensure!( + url.username().is_empty() + && url.password().is_none() + && url.query().is_none() + && url.fragment().is_none(), + "update URL cannot contain credentials, query or fragment" + ); + ensure!(url.as_str().len() <= 2048, "update URL is too long"); + Ok(()) +} + +/// The one planned hostname migration is known at build time. Other callers +/// and test feeds remain confined to the exact configured origin, including +/// port. No wildcard Maple subdomain or arbitrary HTTPS redirect is allowed. +pub(crate) fn origin_allowed(feed: &Url, target: &Url) -> bool { + if feed.origin() == target.origin() { + return true; + } + let approved = |url: &Url| { + url.scheme() == "https" + && url.port_or_known_default() == Some(443) + && matches!( + url.host_str(), + Some("updates.trymaple.ai" | "updates.maple.ai") + ) + }; + approved(feed) && approved(target) +} + +fn validate_range(value: &str, offset: u64, total: u64) -> Result<()> { + let expected = format!("bytes {offset}-{}/{total}", total - 1); + ensure!( + value == expected, + "resumed update range differs from signed payload" + ); + Ok(()) +} + +pub(crate) async fn verify_payload(path: &Path, artifact: &Artifact) -> Result<()> { + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = options + .open(path) + .context("prepared payload is missing or unsafe")?; + ensure!( + file.metadata()?.file_type().is_file() && file.metadata()?.len() == artifact.size, + "update payload has incorrect size" + ); + let mut file = tokio::fs::File::from_std(file); + let mut digest = Sha256::new(); + let mut buffer = [0u8; 64 * 1024]; + let mut read = 0u64; + loop { + let count = file.read(&mut buffer).await?; + if count == 0 { + break; + } + read += count as u64; + ensure!(read <= artifact.size, "update payload exceeds signed size"); + digest.update(&buffer[..count]); + } + ensure!( + read == artifact.size && hex::encode(digest.finalize()) == artifact.sha256, + "update payload SHA-256 mismatch" + ); + Ok(()) +} + +#[cfg(all(test, unix))] +pub(crate) fn test_updater(config: Config) -> Result { + Updater::construct(config, true) +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs new file mode 100644 index 000000000..ba033cff9 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -0,0 +1,1764 @@ +//! Synchronous, fail-closed installation primitives. Run these off the UI thread. +//! +//! The caller authenticates discovery again and holds `InstallLock` plus the +//! profile's startup/host fences through `replace`. Serialized preparation is +//! never authority: every file is compared with the signed payload again. + +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + fs::{self, File, OpenOptions}, + io::{self, Read, Write}, + path::{Component, Path, PathBuf}, +}; + +use flate2::read::MultiGzDecoder; +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::{Artifact, PreparedUpdate, Target}; + +#[cfg(target_os = "macos")] +use std::ffi::CString; +#[cfg(target_os = "macos")] +use std::os::unix::ffi::OsStrExt; +#[cfg(unix)] +use std::os::unix::{fs::MetadataExt, fs::OpenOptionsExt, fs::PermissionsExt}; + +const APP_NAME: &str = "Maple Agent.app"; +const STAGING_PREFIX: &str = ".maple-agent-update-"; +const STAGING_RANDOM_LENGTH: usize = 16; +const RECEIPT_NAME: &str = "installed.json"; +const MAX_EXPANDED_BYTES: u64 = 4 * 1024 * 1024 * 1024; +const MAX_FILE_BYTES: u64 = 2 * 1024 * 1024 * 1024; +const MAX_ENTRIES: usize = 20_000; +const MAX_PATH_BYTES: usize = 1024; +const MAX_EXTENSION_BYTES: u64 = 16 * 1024; +#[cfg(any(target_os = "macos", all(test, unix)))] +const CODESIGN_REQUIREMENT: &str = "=anchor apple generic and identifier \"cloud.opensecret.maple.agent\" and certificate leaf[subject.OU] = \"X773Y823TN\" and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists"; + +#[derive(Debug, Error)] +pub enum InstallError { + #[error("This installation needs a manual download: {0}")] + NotifyOnly(&'static str), + #[error("Another updater is installing this application; try again shortly")] + Busy, + #[error("The prepared update is invalid: {0}")] + Invalid(&'static str), + #[error("The update does not have the required Maple code signature and identity")] + CodeSignature, + #[error("The update could not be installed: {0}")] + Io(#[from] io::Error), +} + +type Result = std::result::Result; + +/// The on-disk values are untrusted on restoration. Keep the authenticated +/// `PreparedUpdate` separately and pass it to every revalidation/replacement. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstallPreparation { + pub installed_path: PathBuf, + pub staging_dir: PathBuf, + pub payload_path: PathBuf, + pub staged_path: PathBuf, + pub version: String, + pub build: String, + pub size: u64, + pub sha256: String, +} + +/// The previous bundle stays at `backup_path` until a successful new startup. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InstalledUpdate { + pub installed_path: PathBuf, + pub staging_dir: PathBuf, + pub backup_path: PathBuf, + pub version: String, + pub build: String, + /// The swap committed, but a later directory synchronization failed. + /// Relaunch the new app and keep the previous app until its checkpoint. + #[serde(default)] + pub durability_warning: bool, +} + +/// Separate from profile locks: two profiles must not replace the same app. +pub struct InstallLock { + _file: File, +} + +/// Find a supported installation from the real executable, never a saved path. +/// AppImage updates remain notify-only until the packaged Linux launch gate. +pub fn discover_installation(executable: &Path, _appimage: Option<&Path>) -> Result { + #[cfg(target_os = "macos")] + { + let executable = executable.canonicalize()?; + let app = executable + .parent() + .filter(|p| p.file_name().is_some_and(|n| n == "MacOS")) + .and_then(Path::parent) + .filter(|p| p.file_name().is_some_and(|n| n == "Contents")) + .and_then(Path::parent) + .ok_or(InstallError::NotifyOnly( + "run the installed application bundle", + ))?; + validate_destination(app)?; + let _probe = tempfile::Builder::new() + .prefix(".maple-agent-write-probe-") + .tempfile_in(app.parent().unwrap()) + .map_err(destination_error)?; + Ok(app.to_owned()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = executable; + Err(InstallError::NotifyOnly( + "automatic installation is not enabled on this platform", + )) + } +} + +pub fn acquire_install_lock(installed: &Path) -> Result { + validate_destination(installed)?; + let parent = installed + .parent() + .ok_or(InstallError::Invalid("missing installation parent"))?; + let digest = hex::encode(Sha256::digest(installed.as_os_str().as_encoded_bytes())); + let path = parent.join(format!(".maple-agent-install-{digest}.lock")); + let file = open_regular(&path, true, false)?; + validate_private_file(&file)?; + file.try_lock_exclusive().map_err(|e| { + if e.kind() == io::ErrorKind::WouldBlock { + InstallError::Busy + } else { + e.into() + } + })?; + Ok(InstallLock { _file: file }) +} + +/// The caller retains the application lock for this preparation's entire +/// lifetime, including while Ready, until it is installed or discarded. +pub fn prepare(update: &PreparedUpdate) -> Result { + ensure_supported()?; + prepare_inner(update, Verifier::Native) +} + +pub fn revalidate(preparation: &InstallPreparation, update: &PreparedUpdate) -> Result<()> { + ensure_supported()?; + revalidate_inner(preparation, update, Verifier::Native) +} + +/// Remove a withdrawn/cancelled preparation before replacement. Once an install +/// receipt exists the directory may contain the prior app, so only a verified +/// new startup may clear it through checkpoint cleanup. +pub fn discard_preparation(preparation: &InstallPreparation) -> Result<()> { + validate_destination(&preparation.installed_path)?; + validate_staging(&preparation.installed_path, &preparation.staging_dir)?; + if preparation.payload_path != preparation.staging_dir.join("payload") + || preparation.staged_path != preparation.staging_dir.join(APP_NAME) + || fs::symlink_metadata(preparation.staging_dir.join(RECEIPT_NAME)).is_ok() + { + return Err(InstallError::Invalid( + "cannot discard a changed preparation or retained backup", + )); + } + fs::remove_dir_all(&preparation.staging_dir)?; + sync_directory(preparation.installed_path.parent().unwrap()) +} + +/// Requires the caller to retain the application lock and profile host fences. +/// It rechecks the downloaded archive, every staged byte, and the OS signature +/// immediately before the atomic swap. No downloaded executable is launched. +pub fn replace( + preparation: &InstallPreparation, + update: &PreparedUpdate, +) -> Result { + ensure_supported()?; + replace_inner(preparation, update, Verifier::Native, Synchronizer::Native) +} + +fn replace_inner( + preparation: &InstallPreparation, + update: &PreparedUpdate, + verifier: Verifier, + synchronizer: Synchronizer, +) -> Result { + revalidate_inner(preparation, update, verifier)?; + let mut installed = InstalledUpdate { + installed_path: preparation.installed_path.clone(), + staging_dir: preparation.staging_dir.clone(), + backup_path: preparation.staged_path.clone(), + version: preparation.version.clone(), + build: preparation.build.clone(), + durability_warning: false, + }; + // A receipt preceding the swap survives a crash before profile-state save. + // Old startups cannot clear it: checkpoint requires the NEW version/build. + write_receipt(&installed)?; + if let Err(error) = swap_bundles(&preparation.installed_path, &preparation.staged_path) { + // Failed atomic swaps leave both paths unchanged. Remove the pending + // receipt so the caller can discard or retry this preparation. + let _ = fs::remove_file(preparation.staging_dir.join(RECEIPT_NAME)); + return Err(error); + } + // Once swap succeeds this is a committed install, including on an I/O + // error while making directory changes durable. Returning an ordinary Err + // would keep the old process running after its host has been stopped. + // Always hand the new installation to relaunch; retain the receipt and old + // bundle for its checkpoint regardless of synchronization outcome. + let stage_synced = synchronizer.sync(&preparation.staging_dir).is_ok(); + let parent_synced = synchronizer + .sync(preparation.installed_path.parent().unwrap()) + .is_ok(); + installed.durability_warning = !stage_synced || !parent_synced; + Ok(installed) +} + +impl InstalledUpdate { + /// Call only after the new app reached its startup checkpoint. On failure, + /// retain the backup; automatic crash rollback is deliberately not implied. + pub fn checkpoint_cleanup(&self) -> Result<()> { + let _lock = acquire_install_lock(&self.installed_path)?; + self.cleanup_locked() + } + + fn cleanup_locked(&self) -> Result<()> { + validate_staging(&self.installed_path, &self.staging_dir)?; + if self.backup_path != self.staging_dir.join(APP_NAME) { + return Err(InstallError::Invalid("backup path changed")); + } + verify_bundle(&self.installed_path, &self.version, &self.build)?; + fs::remove_dir_all(&self.staging_dir)?; + sync_directory(self.installed_path.parent().unwrap()) + } +} + +/// Recover receipts even if the process exited after swapping but before saving +/// its profile state. Invalid/stale receipts leave their backups untouched. +pub fn cleanup_after_startup(installed: &Path, version: &str, build: &str) -> Result { + ensure_supported()?; + let _lock = acquire_install_lock(installed)?; + verify_bundle(installed, version, build)?; + cleanup_staging_locked(installed, version, build) +} + +// An active preparation retains the application lock even while the user +// defers Restart. Once this lock is available, no receipt means a crashed or +// cancelled preparation, rather than another process's live Ready update. +fn cleanup_staging_locked(installed: &Path, version: &str, build: &str) -> Result { + let parent = installed + .parent() + .ok_or(InstallError::Invalid("missing installation parent"))?; + let mut cleaned = 0; + for entry in fs::read_dir(parent)?.take(4096) { + let entry = entry?; + if !entry + .file_name() + .to_str() + .is_some_and(|n| n.starts_with(STAGING_PREFIX)) + { + continue; + } + let root = entry.path(); + if validate_staging(installed, &root).is_err() { + continue; + } + match fs::symlink_metadata(root.join(RECEIPT_NAME)) { + Err(error) if error.kind() == io::ErrorKind::NotFound => { + fs::remove_dir_all(&root)?; + sync_directory(parent)?; + cleaned += 1; + continue; + } + // Any receipt, including malformed or unreadable state, may be a + // retained prior app after a committed swap. Preserve uncertainty. + Ok(_) => {} + Err(_) => continue, + } + let Ok(mut receipt) = open_regular(&root.join(RECEIPT_NAME), false, false) else { + continue; + }; + if receipt.metadata()?.len() > 16 * 1024 { + continue; + } + let mut bytes = Vec::new(); + Read::by_ref(&mut receipt) + .take(16 * 1024 + 1) + .read_to_end(&mut bytes)?; + let Ok(saved) = serde_json::from_slice::(&bytes) else { + continue; + }; + if saved.installed_path == installed + && saved.staging_dir == root + && saved.version == version + && saved.build == build + && saved.cleanup_locked().is_ok() + { + cleaned += 1; + } + } + Ok(cleaned) +} + +/// Packaging assigns CFBundleVersion after compilation. Read it from the +/// current bundle, then authenticate it before trusting a matching receipt. +pub fn cleanup_current_installation(installed: &Path, running_version: &str) -> Result { + ensure_supported()?; + let (version, build) = bundle_version(installed)?; + if version != running_version { + return Err(InstallError::Invalid( + "installed version differs from running app", + )); + } + verify_bundle(installed, &version, &build)?; + cleanup_after_startup(installed, &version, &build) +} + +fn ensure_supported() -> Result<()> { + if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + Ok(()) + } else { + Err(InstallError::NotifyOnly( + "automatic installation is not enabled on this platform", + )) + } +} + +#[derive(Clone, Copy)] +enum Verifier { + Native, + #[cfg(all(test, unix))] + Test(fn(&Path, &str, &str) -> Result<()>), +} + +#[derive(Clone, Copy)] +enum Synchronizer { + Native, + #[cfg(all(test, target_os = "macos"))] + Test(fn(&Path) -> Result<()>), +} + +impl Synchronizer { + fn sync(self, directory: &Path) -> Result<()> { + match self { + Self::Native => sync_directory(directory), + #[cfg(all(test, target_os = "macos"))] + Self::Test(sync) => sync(directory), + } + } +} + +impl Verifier { + fn verify(self, bundle: &Path, version: &str, build: &str) -> Result<()> { + match self { + Self::Native => verify_bundle(bundle, version, build), + #[cfg(all(test, unix))] + Self::Test(verify) => verify(bundle, version, build), + } + } +} + +fn expected_artifact(update: &PreparedUpdate) -> Result<&Artifact> { + crate::manifest::validate_manifest(&update.release.manifest) + .map_err(|_| InstallError::Invalid("manifest fields changed"))?; + update + .release + .manifest + .artifacts + .get(Target::DarwinAarch64.key()) + .ok_or(InstallError::Invalid("missing macOS payload")) +} + +fn prepare_inner(update: &PreparedUpdate, verifier: Verifier) -> Result { + validate_destination(&update.installed_path)?; + let artifact = expected_artifact(update)?; + validate_upgrade_destination(update, verifier)?; + let parent = update.installed_path.parent().unwrap(); + let temporary = tempfile::Builder::new() + .prefix(STAGING_PREFIX) + .rand_bytes(STAGING_RANDOM_LENGTH) + .tempdir_in(parent) + .map_err(destination_error)?; + #[cfg(unix)] + fs::set_permissions(temporary.path(), fs::Permissions::from_mode(0o700))?; + let payload_path = temporary.path().join("payload"); + copy_verified_payload(&update.payload_path, &payload_path, artifact)?; + let expected = scan_archive(&payload_path, Some(temporary.path()))?; + let staged_path = temporary.path().join(APP_NAME); + if scan_tree(&staged_path)? != expected { + return Err(InstallError::Invalid("staged content differs from archive")); + } + verifier.verify( + &staged_path, + &update.release.manifest.version, + &update.release.manifest.build, + )?; + sync_directory(temporary.path())?; + let preparation = InstallPreparation { + installed_path: update.installed_path.clone(), + staging_dir: temporary.path().to_owned(), + payload_path, + staged_path, + version: update.release.manifest.version.clone(), + build: update.release.manifest.build.clone(), + size: artifact.size, + sha256: artifact.sha256.clone(), + }; + let _ = temporary.keep(); + Ok(preparation) +} + +fn revalidate_inner( + preparation: &InstallPreparation, + update: &PreparedUpdate, + verifier: Verifier, +) -> Result<()> { + validate_destination(&update.installed_path)?; + validate_upgrade_destination(update, verifier)?; + validate_staging(&update.installed_path, &preparation.staging_dir)?; + let artifact = expected_artifact(update)?; + if preparation.installed_path != update.installed_path + || preparation.payload_path != preparation.staging_dir.join("payload") + || preparation.staged_path != preparation.staging_dir.join(APP_NAME) + || preparation.version != update.release.manifest.version + || preparation.build != update.release.manifest.build + || preparation.size != artifact.size + || preparation.sha256 != artifact.sha256 + { + return Err(InstallError::Invalid("persisted preparation changed")); + } + verify_payload(&preparation.payload_path, artifact)?; + let expected = scan_archive(&preparation.payload_path, None)?; + if scan_tree(&preparation.staged_path)? != expected { + return Err(InstallError::Invalid( + "staged content differs from signed archive", + )); + } + verifier.verify( + &preparation.staged_path, + &preparation.version, + &preparation.build, + ) +} + +fn validate_upgrade_destination(update: &PreparedUpdate, verifier: Verifier) -> Result<()> { + let (version, build) = bundle_version(&update.installed_path)?; + let installed = crate::manifest::stable_version(&version) + .map_err(|_| InstallError::Invalid("invalid installed version"))?; + let proposed = crate::manifest::stable_version(&update.release.manifest.version) + .map_err(|_| InstallError::Invalid("invalid proposed version"))?; + if proposed <= installed { + return Err(InstallError::Invalid( + "installation already has this version or a newer version", + )); + } + verifier.verify(&update.installed_path, &version, &build) +} + +fn validate_destination(installed: &Path) -> Result<()> { + if !installed.is_absolute() + || installed + .to_str() + .is_none_or(|path| path.chars().any(char::is_control)) + || installed.file_name().is_none_or(|n| n != APP_NAME) + || installed + .components() + .any(|c| matches!(c, Component::ParentDir | Component::CurDir)) + || installed + .components() + .any(|c| c.as_os_str() == "AppTranslocation") + || installed.starts_with("/Volumes") + { + return Err(InstallError::NotifyOnly( + "move Maple Agent to a writable installed application directory", + )); + } + if fs::symlink_metadata(installed)?.file_type().is_symlink() + || !fs::symlink_metadata(installed)?.is_dir() + || installed.canonicalize()? != installed + { + return Err(InstallError::Invalid( + "installation path is not a canonical application directory", + )); + } + Ok(()) +} + +fn destination_error(error: io::Error) -> InstallError { + if error.kind() == io::ErrorKind::PermissionDenied + || matches!(error.raw_os_error(), Some(libc::EROFS | libc::EPERM)) + { + InstallError::NotifyOnly("the application directory is not writable") + } else { + error.into() + } +} + +fn validate_staging(installed: &Path, root: &Path) -> Result<()> { + if root.parent() != installed.parent() + || !root.file_name().and_then(|n| n.to_str()).is_some_and(|n| { + n.strip_prefix(STAGING_PREFIX).is_some_and(|suffix| { + suffix.len() == STAGING_RANDOM_LENGTH + && suffix.bytes().all(|b| b.is_ascii_alphanumeric()) + }) + }) + || root.canonicalize()? != root + { + return Err(InstallError::Invalid( + "staging is not a private installation sibling", + )); + } + let metadata = fs::symlink_metadata(root)?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err(InstallError::Invalid("staging directory changed")); + } + #[cfg(unix)] + if metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o777 != 0o700 + || metadata.dev() != fs::metadata(installed)?.dev() + { + return Err(InstallError::Invalid( + "staging ownership, permissions or volume changed", + )); + } + Ok(()) +} + +fn open_regular(path: &Path, create: bool, exclusive: bool) -> Result { + let mut options = OpenOptions::new(); + options + .read(true) + .write(create) + .create(create) + .create_new(exclusive); + #[cfg(unix)] + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + let file = options.open(path)?; + if !file.metadata()?.is_file() { + return Err(InstallError::Invalid("expected a regular file")); + } + Ok(file) +} + +fn validate_private_file(file: &File) -> Result<()> { + #[cfg(not(unix))] + let _ = file; + #[cfg(unix)] + { + let metadata = file.metadata()?; + if metadata.nlink() != 1 + || metadata.uid() != unsafe { libc::geteuid() } + || metadata.mode() & 0o077 != 0 + { + return Err(InstallError::Invalid( + "private file permissions or ownership changed", + )); + } + } + Ok(()) +} + +fn verify_payload(path: &Path, artifact: &Artifact) -> Result<()> { + let mut file = open_regular(path, false, false)?; + if file.metadata()?.len() != artifact.size { + return Err(InstallError::Invalid("payload size changed")); + } + if hex::encode(hash_reader(&mut file, artifact.size)?) != artifact.sha256 { + return Err(InstallError::Invalid("payload digest changed")); + } + Ok(()) +} + +fn copy_verified_payload(source: &Path, destination: &Path, artifact: &Artifact) -> Result<()> { + let mut input = open_regular(source, false, false)?; + if input.metadata()?.len() != artifact.size { + return Err(InstallError::Invalid("payload size changed")); + } + let mut output = open_regular(destination, true, true)?; + let mut hasher = Sha256::new(); + let mut remaining = artifact.size; + let mut buffer = [0u8; 64 * 1024]; + while remaining > 0 { + let length = remaining.min(buffer.len() as u64) as usize; + let read = input.read(&mut buffer[..length])?; + if read == 0 { + return Err(InstallError::Invalid("payload truncated")); + } + output.write_all(&buffer[..read])?; + hasher.update(&buffer[..read]); + remaining -= read as u64; + } + if input.read(&mut buffer[..1])? != 0 || hex::encode(hasher.finalize()) != artifact.sha256 { + return Err(InstallError::Invalid("payload digest changed")); + } + output.sync_all()?; + Ok(()) +} + +fn hash_reader(reader: &mut impl Read, expected: u64) -> Result<[u8; 32]> { + let mut hasher = Sha256::new(); + let mut read = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let count = reader.read(&mut buffer)?; + if count == 0 { + break; + } + read = read + .checked_add(count as u64) + .ok_or(InstallError::Invalid("size overflow"))?; + if read > expected { + return Err(InstallError::Invalid("file grew during verification")); + } + hasher.update(&buffer[..count]); + } + if read != expected { + return Err(InstallError::Invalid("file truncated during verification")); + } + Ok(hasher.finalize().into()) +} + +#[derive(Debug, PartialEq, Eq)] +enum Node { + Directory, + File { + mode: u32, + size: u64, + digest: [u8; 32], + }, + Symlink(String), +} + +/// Raw iteration prevents tar's GNU/PAX preprocessing from allocating an +/// attacker-declared extension size. Extensions are bounded and parsed here; +/// no ownership, xattrs, flags, hard links, sparse or special files are applied. +fn scan_archive(payload: &Path, output: Option<&Path>) -> Result> { + let input = open_regular(payload, false, false)?; + let decoded = MultiGzDecoder::new(input); + let bounded = ExpansionLimit { + inner: decoded, + remaining: MAX_EXPANDED_BYTES, + }; + let mut archive = tar::Archive::new(bounded); + let mut nodes = BTreeMap::new(); + let mut explicit = BTreeSet::new(); + let mut pax = BTreeMap::new(); + let mut long_name = None; + let mut long_link = None; + let mut expanded = 0u64; + for (count, entry) in archive.entries()?.raw(true).enumerate() { + if count >= MAX_ENTRIES { + return Err(InstallError::Invalid("too many archive members")); + } + let mut entry = entry?; + let kind = entry.header().entry_type(); + let size = entry.size(); + if kind.is_pax_local_extensions() || kind.is_gnu_longname() || kind.is_gnu_longlink() { + if size > MAX_EXTENSION_BYTES { + return Err(InstallError::Invalid("oversized archive extension")); + } + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes)?; + if kind.is_pax_local_extensions() { + if !pax.is_empty() { + return Err(InstallError::Invalid("duplicate archive extensions")); + } + for extension in tar::PaxExtensions::new(&bytes) { + let extension = extension?; + let key = extension + .key() + .map_err(|_| InstallError::Invalid("invalid archive extension"))?; + let value = extension + .value() + .map_err(|_| InstallError::Invalid("invalid archive extension"))?; + if !matches!( + key, + "path" + | "linkpath" + | "mtime" + | "atime" + | "ctime" + | "uid" + | "gid" + | "uname" + | "gname" + ) || pax.insert(key.to_owned(), value.to_owned()).is_some() + { + return Err(InstallError::Invalid("unsupported archive metadata")); + } + } + } else { + let value = std::str::from_utf8(bytes.strip_suffix(&[0]).unwrap_or(&bytes)) + .map_err(|_| InstallError::Invalid("non-UTF-8 archive path"))? + .to_owned(); + let previous = if kind.is_gnu_longname() { + long_name.replace(value) + } else { + long_link.replace(value) + }; + if previous.is_some() { + return Err(InstallError::Invalid("duplicate archive long name")); + } + } + continue; + } + let header_path = entry.header().path_bytes(); + let header_path = std::str::from_utf8(&header_path) + .map_err(|_| InstallError::Invalid("non-UTF-8 archive path"))?; + let name = pax + .remove("path") + .or(long_name.take()) + .unwrap_or_else(|| header_path.to_owned()); + let name = archive_path(&name, kind.is_dir())?; + if !explicit.insert(name.clone()) { + return Err(InstallError::Invalid("duplicate archive path")); + } + let mode = entry.header().mode()?; + let node = if kind.is_dir() { + if mode != 0o755 || size != 0 { + return Err(InstallError::Invalid("unsafe archive directory metadata")); + } + if let Some(root) = output { + create_public_directory(&root.join(&name))?; + } + Node::Directory + } else if kind.is_file() { + if !matches!(mode, 0o644 | 0o755) || size > MAX_FILE_BYTES { + return Err(InstallError::Invalid("unsafe file size or permissions")); + } + expanded = expanded + .checked_add(size) + .ok_or(InstallError::Invalid("expanded size overflow"))?; + if expanded > MAX_EXPANDED_BYTES { + return Err(InstallError::Invalid("archive expansion limit exceeded")); + } + let digest = if let Some(root) = output { + let destination = root.join(&name); + create_public_directory(destination.parent().unwrap())?; + let mut file = open_regular(&destination, true, true)?; + let mut hasher = Sha256::new(); + let mut copied = 0u64; + let mut buffer = [0u8; 64 * 1024]; + loop { + let length = entry.read(&mut buffer)?; + if length == 0 { + break; + } + copied += length as u64; + if copied > size { + return Err(InstallError::Invalid("archive member grew")); + } + file.write_all(&buffer[..length])?; + hasher.update(&buffer[..length]); + } + if copied != size { + return Err(InstallError::Invalid("truncated archive member")); + } + set_mode(&destination, mode)?; + file.sync_all()?; + hasher.finalize().into() + } else { + hash_reader(&mut entry, size)? + }; + Node::File { mode, size, digest } + } else if kind.is_symlink() { + if size != 0 { + return Err(InstallError::Invalid("symlink contains file data")); + } + let header_link = entry + .header() + .link_name_bytes() + .ok_or(InstallError::Invalid("missing symlink target"))?; + let header_link = std::str::from_utf8(&header_link) + .map_err(|_| InstallError::Invalid("non-UTF-8 symlink"))?; + let link = pax + .remove("linkpath") + .or(long_link.take()) + .unwrap_or_else(|| header_link.to_owned()); + validate_link(&name, &link)?; + Node::Symlink(link) + } else { + return Err(InstallError::Invalid( + "hard links, sparse and special files are not allowed", + )); + }; + if pax.contains_key("linkpath") || long_link.is_some() { + return Err(InstallError::Invalid("link metadata on a non-link")); + } + pax.clear(); + nodes.insert(name, node); + } + if !pax.is_empty() || long_name.is_some() || long_link.is_some() { + return Err(InstallError::Invalid("unconsumed archive extension")); + } + if nodes.get(APP_NAME) != Some(&Node::Directory) { + return Err(InstallError::Invalid("missing application root")); + } + let paths: Vec = nodes.keys().cloned().collect(); + for name in paths { + let mut parent = Path::new(&name).parent(); + while let Some(path) = parent.filter(|p| !p.as_os_str().is_empty()) { + let parent_name = path + .to_str() + .ok_or(InstallError::Invalid("invalid archive path"))? + .to_owned(); + match nodes.get(&parent_name) { + Some(Node::Directory) => {} + Some(_) => { + return Err(InstallError::Invalid( + "archive writes through a file or symlink", + )); + } + None => { + nodes.insert(parent_name, Node::Directory); + } + } + parent = path.parent(); + } + } + validate_symlink_graph(&nodes)?; + if let Some(root) = output { + for (name, node) in &nodes { + if let Node::Symlink(link) = node { + let destination = root.join(name); + create_public_directory(destination.parent().unwrap())?; + #[cfg(unix)] + std::os::unix::fs::symlink(link, destination)?; + #[cfg(not(unix))] + { + let _ = link; + return Err(InstallError::NotifyOnly( + "archive links are unsupported on this platform", + )); + } + } + } + for (name, node) in nodes.iter().rev() { + if matches!(node, Node::Directory) { + sync_directory(&root.join(name))?; + } + } + } + Ok(nodes) +} + +fn archive_path(raw: &str, directory: bool) -> Result { + let name = if directory { + raw.strip_suffix('/').unwrap_or(raw) + } else { + raw + }; + if name.len() > MAX_PATH_BYTES + || name.is_empty() + || name.contains('\\') + || name.chars().any(char::is_control) + || name + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + || name.split('/').next() != Some(APP_NAME) + { + return Err(InstallError::Invalid("unsafe archive path")); + } + Ok(name.to_owned()) +} + +fn validate_link(name: &str, link: &str) -> Result<()> { + if link.is_empty() + || link.len() > MAX_PATH_BYTES + || link.starts_with('/') + || link.contains('\\') + || link.chars().any(char::is_control) + { + return Err(InstallError::Invalid("unsafe symlink target")); + } + let mut resolved: Vec<&str> = name.split('/').collect(); + resolved.pop(); + for part in link.split('/') { + match part { + "" | "." => {} + ".." => { + if resolved.len() <= 1 { + return Err(InstallError::Invalid("symlink escapes application")); + } + resolved.pop(); + } + _ => resolved.push(part), + } + } + if resolved.first() != Some(&APP_NAME) { + return Err(InstallError::Invalid("symlink escapes application")); + } + Ok(()) +} + +// Lexical containment alone is insufficient: `dir/parent/../outside` escapes +// when `dir/parent -> ..`. Resolve links against the complete virtual tree, +// including links appearing later in the archive, before creating any of them. +fn validate_symlink_graph(nodes: &BTreeMap) -> Result<()> { + for (name, node) in nodes { + let Node::Symlink(target) = node else { + continue; + }; + let parent = name + .rsplit_once('/') + .ok_or(InstallError::Invalid("invalid archive symlink"))? + .0; + let mut pending: VecDeque = parent + .split('/') + .chain(target.split('/')) + .map(str::to_owned) + .collect(); + let mut resolved = Vec::new(); + let mut followed = 0; + while let Some(part) = pending.pop_front() { + match part.as_str() { + "" | "." => continue, + ".." => { + if resolved.len() <= 1 { + return Err(InstallError::Invalid("symlink chain escapes application")); + } + resolved.pop(); + } + _ => { + resolved.push(part); + match nodes.get(&resolved.join("/")) { + Some(Node::Symlink(link)) => { + followed += 1; + if followed > 40 { + return Err(InstallError::Invalid("cyclic or excessive symlinks")); + } + resolved.pop(); + for part in link.split('/').rev() { + pending.push_front(part.to_owned()); + } + } + Some(Node::Directory) => {} + Some(Node::File { .. }) if pending.is_empty() => {} + Some(Node::File { .. }) => { + return Err(InstallError::Invalid("symlink traverses a file")); + } + None => return Err(InstallError::Invalid("dangling archive symlink")), + } + } + } + } + } + Ok(()) +} + +fn create_public_directory(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(InstallError::Invalid("archive parent is a file or symlink")); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => { + let parent = path + .parent() + .ok_or(InstallError::Invalid("missing archive parent"))?; + // Recurse only for missing ancestors, preserving the private 0700 + // staging root while giving implicit bundle directories 0755. + if !parent.exists() { + create_public_directory(parent)?; + } + fs::create_dir(path)?; + } + Err(error) => return Err(error.into()), + } + set_mode(path, 0o755) +} + +fn set_mode(path: &Path, mode: u32) -> Result<()> { + #[cfg(unix)] + fs::set_permissions(path, fs::Permissions::from_mode(mode))?; + #[cfg(not(unix))] + { + let _ = (path, mode); + } + Ok(()) +} + +fn scan_tree(bundle: &Path) -> Result> { + let mut nodes = BTreeMap::new(); + let parent = bundle + .parent() + .ok_or(InstallError::Invalid("missing bundle parent"))?; + let mut pending = vec![bundle.to_owned()]; + let mut total = 0u64; + while let Some(path) = pending.pop() { + if nodes.len() >= MAX_ENTRIES { + return Err(InstallError::Invalid("too many staged files")); + } + let name = path + .strip_prefix(parent) + .ok() + .and_then(Path::to_str) + .ok_or(InstallError::Invalid("invalid staged path"))?; + let metadata = fs::symlink_metadata(&path)?; + let node = if metadata.file_type().is_symlink() { + let link = fs::read_link(&path)?; + let link = link + .to_str() + .ok_or(InstallError::Invalid("invalid staged symlink"))?; + validate_link(name, link)?; + Node::Symlink(link.to_owned()) + } else if metadata.is_dir() { + #[cfg(unix)] + if metadata.mode() & 0o7777 != 0o755 { + return Err(InstallError::Invalid( + "staged directory permissions changed", + )); + } + for entry in fs::read_dir(&path)? { + pending.push(entry?.path()); + } + Node::Directory + } else if metadata.is_file() { + #[cfg(unix)] + if metadata.nlink() != 1 { + return Err(InstallError::Invalid("staged file is hard-linked")); + } + total = total + .checked_add(metadata.len()) + .ok_or(InstallError::Invalid("staged size overflow"))?; + if metadata.len() > MAX_FILE_BYTES || total > MAX_EXPANDED_BYTES { + return Err(InstallError::Invalid("staged content is too large")); + } + let mut file = open_regular(&path, false, false)?; + #[cfg(unix)] + let mode = metadata.mode() & 0o7777; + #[cfg(not(unix))] + let mode = 0o644; + Node::File { + mode, + size: metadata.len(), + digest: hash_reader(&mut file, metadata.len())?, + } + } else { + return Err(InstallError::Invalid("staged special file")); + }; + nodes.insert(name.to_owned(), node); + } + validate_symlink_graph(&nodes)?; + Ok(nodes) +} + +struct ExpansionLimit { + inner: R, + remaining: u64, +} +impl Read for ExpansionLimit { + fn read(&mut self, output: &mut [u8]) -> io::Result { + if output.is_empty() { + return Ok(0); + } + if self.remaining == 0 { + return Err(io::Error::other("archive expansion limit exceeded")); + } + let length = output.len().min(self.remaining as usize); + let read = self.inner.read(&mut output[..length])?; + self.remaining -= read as u64; + Ok(read) + } +} + +fn bundle_dictionary(bundle: &Path) -> Result { + let plist_path = bundle.join("Contents/Info.plist"); + let plist = open_regular(&plist_path, false, false)?; + if plist.metadata()?.len() > 1024 * 1024 { + return Err(InstallError::CodeSignature); + } + let value = plist::Value::from_reader(plist).map_err(|_| InstallError::CodeSignature)?; + value.into_dictionary().ok_or(InstallError::CodeSignature) +} + +fn bundle_version(bundle: &Path) -> Result<(String, String)> { + let dictionary = bundle_dictionary(bundle)?; + let version = dictionary + .get("CFBundleShortVersionString") + .and_then(plist::Value::as_string) + .ok_or(InstallError::CodeSignature)? + .to_owned(); + let build = dictionary + .get("CFBundleVersion") + .and_then(plist::Value::as_string) + .ok_or(InstallError::CodeSignature)? + .to_owned(); + Ok((version, build)) +} + +fn verify_bundle(bundle: &Path, version: &str, build: &str) -> Result<()> { + let dictionary = bundle_dictionary(bundle)?; + for (key, expected) in [ + ("CFBundleIdentifier", "cloud.opensecret.maple.agent"), + ("CFBundleExecutable", "maple-agent"), + ("CFBundleShortVersionString", version), + ("CFBundleVersion", build), + ] { + if dictionary.get(key).and_then(plist::Value::as_string) != Some(expected) { + return Err(InstallError::CodeSignature); + } + } + if dictionary.contains_key("LSEnvironment") { + return Err(InstallError::CodeSignature); + } + let executable = fs::symlink_metadata(bundle.join("Contents/MacOS/maple-agent"))?; + if !executable.is_file() || executable.file_type().is_symlink() { + return Err(InstallError::CodeSignature); + } + #[cfg(unix)] + if executable.mode() & 0o111 != 0o111 || executable.mode() & 0o6000 != 0 { + return Err(InstallError::CodeSignature); + } + #[cfg(target_os = "macos")] + { + let status = std::process::Command::new("/usr/bin/codesign") + .args([ + "--verify", + "--deep", + "--strict", + "--all-architectures", + "--test-requirement", + CODESIGN_REQUIREMENT, + ]) + .arg(bundle) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status()?; + if !status.success() { + return Err(InstallError::CodeSignature); + } + // CodeDirectory's numeric CS_RUNTIME flag is authoritative. Do not + // accept the word "runtime" appearing elsewhere in diagnostics. + let mut child = std::process::Command::new("/usr/bin/codesign") + .args(["--display", "--verbose=4"]) + .arg(bundle) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn()?; + let mut output = Vec::new(); + let read = child + .stderr + .take() + .ok_or(InstallError::CodeSignature)? + .take(32 * 1024 + 1) + .read_to_end(&mut output); + if read.is_err() || output.len() > 32 * 1024 { + let _ = child.kill(); + let _ = child.wait(); + return Err(InstallError::CodeSignature); + } + if !child.wait()?.success() || !has_hardened_runtime(&output) { + return Err(InstallError::CodeSignature); + } + Ok(()) + } + #[cfg(not(target_os = "macos"))] + Err(InstallError::NotifyOnly( + "macOS signature verification is unavailable", + )) +} + +#[cfg(any(target_os = "macos", all(test, unix)))] +fn has_hardened_runtime(output: &[u8]) -> bool { + let Ok(output) = std::str::from_utf8(output) else { + return false; + }; + let mut directories = output + .lines() + .filter(|line| line.starts_with("CodeDirectory ")); + let Some(directory) = directories.next() else { + return false; + }; + if directories.next().is_some() { + return false; + } + let mut flags = directory + .split_ascii_whitespace() + .filter_map(|part| part.strip_prefix("flags=0x")); + let Some(flag) = flags.next() else { + return false; + }; + if flags.next().is_some() { + return false; + } + let hexadecimal = flag.split('(').next().unwrap_or(""); + u32::from_str_radix(hexadecimal, 16).is_ok_and(|flags| flags & 0x10000 != 0) +} + +fn write_receipt(installed: &InstalledUpdate) -> Result<()> { + let destination = installed.staging_dir.join(RECEIPT_NAME); + let bytes = serde_json::to_vec(installed) + .map_err(|_| InstallError::Invalid("invalid install receipt"))?; + let mut temporary = tempfile::NamedTempFile::new_in(&installed.staging_dir)?; + temporary.write_all(&bytes)?; + temporary.as_file().sync_all()?; + temporary + .persist(&destination) + .map_err(|error| InstallError::Io(error.error))?; + sync_directory(&installed.staging_dir) +} + +fn sync_directory(path: &Path) -> Result<()> { + let file = File::open(path)?; + file.sync_all()?; + Ok(()) +} + +fn swap_bundles(installed: &Path, staged: &Path) -> Result<()> { + #[cfg(target_os = "macos")] + { + let installed = CString::new(installed.as_os_str().as_bytes()) + .map_err(|_| InstallError::Invalid("invalid installed path"))?; + let staged = CString::new(staged.as_os_str().as_bytes()) + .map_err(|_| InstallError::Invalid("invalid staged path"))?; + // Both paths were revalidated as ordinary directories on one volume. + // RENAME_SWAP preserves the prior app at staged even if power is lost. + let result = + unsafe { libc::renamex_np(installed.as_ptr(), staged.as_ptr(), libc::RENAME_SWAP) }; + if result != 0 { + return Err(io::Error::last_os_error().into()); + } + Ok(()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = (installed, staged); + Err(InstallError::NotifyOnly( + "atomic bundle replacement is unavailable", + )) + } +} + +// These cases intentionally exercise Unix permissions, directory fsync, and +// framework symlinks. Windows CI provides compilation coverage, not installation. +#[cfg(all(test, unix))] +mod tests { + use super::*; + use crate::{Downloads, Manifest, VerifiedRelease}; + use flate2::{Compression, write::GzEncoder}; + + enum Member<'a> { + Directory(&'a str), + File(&'a str, &'a [u8], u32), + Link(&'a str, &'a str), + Special(&'a str, u8), + } + + fn archive(path: &Path, members: &[Member<'_>]) { + let output = File::create(path).unwrap(); + let encoder = GzEncoder::new(output, Compression::fast()); + let mut builder = tar::Builder::new(encoder); + for member in members { + let mut header = tar::Header::new_ustar(); + let (name, data, kind, mode, link) = match member { + Member::Directory(name) => (*name, &[][..], b'5', 0o755, None), + Member::File(name, data, mode) => (*name, *data, b'0', *mode, None), + Member::Link(name, target) => (*name, &[][..], b'2', 0o777, Some(*target)), + Member::Special(name, kind) => (*name, &[][..], *kind, 0o644, None), + }; + header.set_mode(mode); + header.set_size(data.len() as u64); + header.set_entry_type(tar::EntryType::new(kind)); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + // Raw mutation permits deliberately unsafe path fixtures that the + // tar writer's convenience path API would correctly reject. + let bytes = header.as_mut_bytes(); + assert!(name.len() < 100); + bytes[..100].fill(0); + bytes[..name.len()].copy_from_slice(name.as_bytes()); + if let Some(link) = link { + header.set_link_name(link).unwrap(); + } + header.set_cksum(); + builder.append(&header, data).unwrap(); + } + builder + .into_inner() + .unwrap() + .finish() + .unwrap() + .sync_all() + .unwrap(); + } + + fn fixture_members() -> Vec> { + vec![ + Member::Directory(APP_NAME), + Member::Directory("Maple Agent.app/Contents"), + Member::File( + "Maple Agent.app/Contents/maple-agent", + b"verified package executable", + 0o755, + ), + Member::Directory("Maple Agent.app/Contents/Frameworks"), + Member::Directory("Maple Agent.app/Contents/Frameworks/A.framework"), + Member::Directory("Maple Agent.app/Contents/Frameworks/A.framework/Versions"), + Member::Directory("Maple Agent.app/Contents/Frameworks/A.framework/Versions/A"), + Member::File( + "Maple Agent.app/Contents/Frameworks/A.framework/Versions/A/A", + b"dylib", + 0o755, + ), + Member::Link( + "Maple Agent.app/Contents/Frameworks/A.framework/Versions/Current", + "A", + ), + Member::Link( + "Maple Agent.app/Contents/Frameworks/A.framework/A", + "Versions/Current/A", + ), + ] + } + + fn write_version(bundle: &Path, version: &str, build: &str) { + fs::create_dir_all(bundle.join("Contents")).unwrap(); + let mut dictionary = plist::Dictionary::new(); + for (key, value) in [ + ("CFBundleIdentifier", "cloud.opensecret.maple.agent"), + ("CFBundleExecutable", "maple-agent"), + ("CFBundleShortVersionString", version), + ("CFBundleVersion", build), + ] { + dictionary.insert(key.to_owned(), value.into()); + } + plist::Value::Dictionary(dictionary) + .to_file_xml(bundle.join("Contents/Info.plist")) + .unwrap(); + } + + fn update_fixture(root: &Path) -> PreparedUpdate { + let installed = root.join(APP_NAME); + write_version(&installed, "0.0.1", "1"); + let payload = root.join("archive.tar.gz"); + archive(&payload, &fixture_members()); + let bytes = fs::read(&payload).unwrap(); + let artifact = Artifact { + path: "/agent/stable/releases/0.0.2/maple-agent.app.tar.gz".into(), + size: bytes.len() as u64, + sha256: hex::encode(Sha256::digest(&bytes)), + }; + let dmg = Artifact { + path: "/agent/stable/releases/0.0.2/maple-agent.dmg".into(), + ..artifact.clone() + }; + PreparedUpdate { + release: VerifiedRelease { + manifest: Manifest { + format: "maple-agent-update-v1".into(), + product: "maple-agent".into(), + channel: "stable".into(), + version: "0.0.2".into(), + build: "2".into(), + source_sha: "a".repeat(40), + pub_date: "2026-10-22T00:00:00Z".into(), + notes: "fixture".into(), + artifacts: BTreeMap::from([("darwin-aarch64".into(), artifact)]), + downloads: Downloads { + macos: dmg, + linux_appimage: None, + }, + }, + envelope: "test-only authenticated caller fixture".into(), + origin: "https://updates.example.test".parse().unwrap(), + }, + payload_path: payload, + installed_path: installed.canonicalize().unwrap(), + } + } + + fn accept_test_signature(_: &Path, _: &str, _: &str) -> Result<()> { + Ok(()) + } + + #[test] + fn extracts_internal_framework_symlinks_and_compares_every_staged_byte() { + let root = tempfile::tempdir().unwrap(); + let payload = root.path().join("package"); + let staging = root.path().join("staging"); + fs::create_dir(&staging).unwrap(); + set_mode(&staging, 0o700).unwrap(); + archive(&payload, &fixture_members()); + let expected = scan_archive(&payload, Some(&staging)).unwrap(); + assert_eq!(expected, scan_tree(&staging.join(APP_NAME)).unwrap()); + assert_eq!(expected, scan_archive(&payload, None).unwrap()); + fs::write( + staging.join("Maple Agent.app/Contents/maple-agent"), + b"tampered", + ) + .unwrap(); + assert_ne!(expected, scan_tree(&staging.join(APP_NAME)).unwrap()); + #[cfg(unix)] + assert_eq!(fs::metadata(&staging).unwrap().mode() & 0o777, 0o700); + } + + #[test] + fn rejects_traversal_duplicate_hardlink_special_and_privileged_members() { + let root = tempfile::tempdir().unwrap(); + let payload = root.path().join("package"); + for member in [ + Member::File("Maple Agent.app/../outside", b"x", 0o644), + Member::File("/Maple Agent.app/absolute", b"x", 0o644), + Member::File("Other.app/Contents/file", b"x", 0o644), + Member::File("Maple Agent.app/./ambiguous", b"x", 0o644), + Member::File("Maple Agent.app/private", b"x", 0o600), + Member::File("Maple Agent.app/setuid", b"x", 0o4755), + Member::Special("Maple Agent.app/fifo", b'6'), + Member::Special("Maple Agent.app/hardlink", b'1'), + Member::Special("Maple Agent.app/sparse", b'S'), + Member::Directory(APP_NAME), + ] { + archive(&payload, &[Member::Directory(APP_NAME), member]); + assert!(scan_archive(&payload, None).is_err()); + } + } + + #[test] + fn rejects_escaping_links_and_writes_through_internal_links_in_both_orders() { + let root = tempfile::tempdir().unwrap(); + let payload = root.path().join("package"); + for target in ["/outside", "../../outside", "../outside", "\\outside"] { + archive( + &payload, + &[ + Member::Directory(APP_NAME), + Member::Link("Maple Agent.app/link", target), + ], + ); + assert!(scan_archive(&payload, None).is_err()); + } + for reversed in [false, true] { + let mut members = vec![ + Member::Directory(APP_NAME), + Member::Link("Maple Agent.app/alias", "Contents"), + Member::File("Maple Agent.app/alias/file", b"x", 0o644), + ]; + if reversed { + members.swap(1, 2); + } + archive(&payload, &members); + assert!(scan_archive(&payload, None).is_err()); + } + archive( + &payload, + &[ + Member::Directory(APP_NAME), + Member::Directory("Maple Agent.app/dir"), + Member::Link("Maple Agent.app/dir/parent", ".."), + Member::Link("Maple Agent.app/link", "dir/parent/../outside"), + ], + ); + assert!(matches!( + scan_archive(&payload, None), + Err(InstallError::Invalid("symlink chain escapes application")) + )); + archive( + &payload, + &[ + Member::Directory(APP_NAME), + Member::Link("Maple Agent.app/cycle", "cycle"), + ], + ); + assert!(scan_archive(&payload, None).is_err()); + } + + #[test] + fn bounds_extensions_before_allocating_and_rejects_unsafe_metadata() { + let root = tempfile::tempdir().unwrap(); + let payload = root.path().join("package"); + let mut encoder = GzEncoder::new(File::create(&payload).unwrap(), Compression::fast()); + let mut header = tar::Header::new_ustar(); + header.set_path("extended").unwrap(); + header.set_entry_type(tar::EntryType::new(b'x')); + header.set_size(MAX_EXTENSION_BYTES + 1); + header.set_cksum(); + // A malicious size must fail before reading its missing body. + encoder.write_all(header.as_bytes()).unwrap(); + encoder.finish().unwrap(); + assert!(matches!( + scan_archive(&payload, None), + Err(InstallError::Invalid("oversized archive extension")) + )); + let encoder = GzEncoder::new(File::create(&payload).unwrap(), Compression::fast()); + let mut builder = tar::Builder::new(encoder); + builder + .append_pax_extensions([( + "SCHILY.xattr.com.apple.quarantine", + b"do not apply".as_slice(), + )]) + .unwrap(); + builder.into_inner().unwrap().finish().unwrap(); + assert!(matches!( + scan_archive(&payload, None), + Err(InstallError::Invalid("unsupported archive metadata")) + )); + } + + #[cfg(target_os = "macos")] + #[test] + fn reads_native_release_tar_format_and_preserves_bundle_modes() { + let root = tempfile::tempdir().unwrap(); + let source = root.path().join("source"); + let bundle = source.join(APP_NAME); + fs::create_dir_all(bundle.join("Contents/Frameworks/A.framework/Versions/A")).unwrap(); + write_version(&bundle, "0.0.2", "2"); + fs::write( + bundle.join("Contents/Frameworks/A.framework/Versions/A/A"), + b"dylib", + ) + .unwrap(); + let mut pending = vec![source.clone()]; + while let Some(path) = pending.pop() { + if path.is_dir() { + set_mode(&path, 0o755).unwrap(); + for entry in fs::read_dir(&path).unwrap() { + pending.push(entry.unwrap().path()); + } + } else { + set_mode(&path, 0o644).unwrap(); + } + } + std::os::unix::fs::symlink( + "A", + bundle.join("Contents/Frameworks/A.framework/Versions/Current"), + ) + .unwrap(); + std::os::unix::fs::symlink( + "Versions/Current/A", + bundle.join("Contents/Frameworks/A.framework/A"), + ) + .unwrap(); + let payload = root.path().join("release.app.tar.gz"); + let status = std::process::Command::new("/usr/bin/tar") + .env("COPYFILE_DISABLE", "1") + .arg("-czf") + .arg(&payload) + .arg("-C") + .arg(&source) + .arg(APP_NAME) + .status() + .unwrap(); + assert!(status.success()); + let output = root.path().join("output"); + fs::create_dir(&output).unwrap(); + let expected = scan_archive(&payload, Some(&output)).unwrap(); + assert_eq!(expected, scan_tree(&output.join(APP_NAME)).unwrap()); + } + + #[test] + fn preparation_rechecks_saved_paths_archive_staging_and_installed_version() { + let root = tempfile::tempdir().unwrap(); + let update = update_fixture(root.path()); + let verifier = Verifier::Test(accept_test_signature); + let prepared = prepare_inner(&update, verifier).unwrap(); + revalidate_inner(&prepared, &update, verifier).unwrap(); + let mut changed = prepared.clone(); + changed.staged_path = update.installed_path.clone(); + assert!(revalidate_inner(&changed, &update, verifier).is_err()); + fs::write(prepared.staged_path.join("extra"), b"not in signed archive").unwrap(); + assert!(revalidate_inner(&prepared, &update, verifier).is_err()); + fs::remove_file(prepared.staged_path.join("extra")).unwrap(); + fs::write(&prepared.payload_path, b"corrupted archive").unwrap(); + assert!(revalidate_inner(&prepared, &update, verifier).is_err()); + write_version(&update.installed_path, "0.0.3", "3"); + assert!(matches!( + prepare_inner(&update, verifier), + Err(InstallError::Invalid( + "installation already has this version or a newer version" + )) + )); + } + + #[test] + fn separate_installers_exclude_each_other_across_profiles() { + let root = tempfile::tempdir().unwrap(); + let installed = root.path().join(APP_NAME); + fs::create_dir(&installed).unwrap(); + let installed = installed.canonicalize().unwrap(); + let first = acquire_install_lock(&installed).unwrap(); + assert!(matches!( + acquire_install_lock(&installed), + Err(InstallError::Busy) + )); + drop(first); + assert!(acquire_install_lock(&installed).is_ok()); + } + + #[test] + fn deferred_preparation_is_protected_then_reclaimed_after_process_lock_release() { + let root = tempfile::tempdir().unwrap(); + let update = update_fixture(root.path()); + let ready_guard = acquire_install_lock(&update.installed_path).unwrap(); + let prepared = prepare_inner(&update, Verifier::Test(accept_test_signature)).unwrap(); + assert!(matches!( + acquire_install_lock(&update.installed_path), + Err(InstallError::Busy) + )); + assert!( + prepared.staging_dir.is_dir(), + "another startup cannot sweep a live Ready preparation" + ); + // A process exit releases this guard even when its preparation was + // deferred. The next startup can safely reclaim its entire stage. + drop(ready_guard); + let _startup_guard = acquire_install_lock(&update.installed_path).unwrap(); + assert_eq!( + cleanup_staging_locked(&update.installed_path, "0.0.1", "1").unwrap(), + 1 + ); + assert!(!prepared.staging_dir.exists()); + assert!(update.installed_path.is_dir()); + assert!(update.payload_path.is_file()); + } + + #[test] + fn orphan_sweep_preserves_any_receipt_and_unrelated_private_directories() { + let root = tempfile::tempdir().unwrap(); + let update = update_fixture(root.path()); + let _startup_guard = acquire_install_lock(&update.installed_path).unwrap(); + let prepared = prepare_inner(&update, Verifier::Test(accept_test_signature)).unwrap(); + fs::write( + prepared.staging_dir.join(RECEIPT_NAME), + b"truncated uncertain committed receipt", + ) + .unwrap(); + let unrelated = tempfile::Builder::new() + .prefix(".maple-agent-update-other-") + .tempdir_in(update.installed_path.parent().unwrap()) + .unwrap(); + assert_eq!( + cleanup_staging_locked(&update.installed_path, "0.0.1", "1").unwrap(), + 0 + ); + assert!(prepared.staging_dir.is_dir()); + assert!(unrelated.path().is_dir()); + } + + #[test] + fn rejects_saved_symlink_staging_and_cross_installation_binding() { + let root = tempfile::tempdir().unwrap(); + let update = update_fixture(root.path()); + let prepared = prepare_inner(&update, Verifier::Test(accept_test_signature)).unwrap(); + let elsewhere = tempfile::tempdir().unwrap(); + let mut changed = prepared.clone(); + changed.staging_dir = elsewhere.path().to_owned(); + assert!( + revalidate_inner(&changed, &update, Verifier::Test(accept_test_signature)).is_err() + ); + #[cfg(unix)] + { + let alias = root.path().join(format!("{STAGING_PREFIX}alias")); + std::os::unix::fs::symlink(&prepared.staging_dir, &alias).unwrap(); + assert!(validate_staging(&update.installed_path, &alias).is_err()); + } + } + + #[test] + fn native_identity_validation_never_accepts_fake_bundle() { + let root = tempfile::tempdir().unwrap(); + let bundle = root.path().join(APP_NAME); + write_version(&bundle, "0.0.2", "2"); + assert!(verify_bundle(&bundle, "0.0.2", "2").is_err()); + assert!(verify_bundle(&bundle, "0.0.3", "2").is_err()); + assert!(verify_bundle(&bundle, "0.0.2", "3").is_err()); + } + + #[test] + fn developer_id_requirement_and_numeric_hardened_runtime_are_mandatory() { + assert!( + CODESIGN_REQUIREMENT.starts_with('='), + "codesign requires = for inline requirement text" + ); + assert!(CODESIGN_REQUIREMENT.contains("identifier \"cloud.opensecret.maple.agent\"")); + assert!(CODESIGN_REQUIREMENT.contains("certificate leaf[subject.OU] = \"X773Y823TN\"")); + assert!( + CODESIGN_REQUIREMENT.contains("certificate 1[field.1.2.840.113635.100.6.2.6] exists") + ); + assert!( + CODESIGN_REQUIREMENT + .contains("certificate leaf[field.1.2.840.113635.100.6.1.13] exists") + ); + assert!(has_hardened_runtime(b"CodeDirectory v=20500 size=1024 flags=0x10000(runtime) hashes=10+7 location=embedded\n")); + for output in [ + "CodeDirectory v=20500 flags=0x0(none)\nruntime=true\n", + "CodeDirectory v=20500 flags=0x2(adhoc)\n", + "CodeDirectory v=20500 flags=0x20000(linker-signed)\n", + "CodeDirectory v=20500 flags=0x100000000(runtime)\n", + "CodeDirectory flags=0x10000(runtime) flags=0x0\n", + "CodeDirectory flags=0x10000(runtime)\nCodeDirectory flags=0x0\n", + "Authority=runtime\n", + ] { + assert!( + !has_hardened_runtime(output.as_bytes()), + "accepted {output:?}" + ); + } + } + + #[cfg(target_os = "macos")] + #[test] + fn requirement_is_accepted_by_the_native_requirement_compiler() { + let status = std::process::Command::new("/usr/bin/csreq") + .args(["-r", CODESIGN_REQUIREMENT]) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .unwrap(); + assert!(status.success()); + } + + #[test] + fn replacement_failure_preserves_current_app_and_staging() { + let root = tempfile::tempdir().unwrap(); + let update = update_fixture(root.path()); + let prepared = prepare_inner(&update, Verifier::Test(accept_test_signature)).unwrap(); + assert!(swap_bundles(&prepared.installed_path, &root.path().join("missing")).is_err()); + assert!(prepared.installed_path.is_dir()); + assert!(prepared.staged_path.is_dir()); + assert_eq!(bundle_version(&prepared.installed_path).unwrap().0, "0.0.1"); + } + + #[cfg(target_os = "macos")] + #[test] + fn successful_swap_returns_committed_restart_path_when_post_swap_sync_fails() { + let root = tempfile::tempdir().unwrap(); + let update = update_fixture(root.path()); + let verifier = Verifier::Test(accept_test_signature); + let prepared = prepare_inner(&update, verifier).unwrap(); + let committed = replace_inner( + &prepared, + &update, + verifier, + Synchronizer::Test(|_| Err(io::Error::other("injected post-swap sync failure").into())), + ) + .expect("successful swap must always return the committed installation"); + assert!(committed.durability_warning); + assert_eq!(committed.installed_path, update.installed_path); + assert_eq!( + fs::read(committed.installed_path.join("Contents/maple-agent")).unwrap(), + b"verified package executable" + ); + assert_eq!(bundle_version(&committed.backup_path).unwrap().0, "0.0.1"); + assert!(committed.staging_dir.join(RECEIPT_NAME).is_file()); + assert!( + discard_preparation(&prepared).is_err(), + "backup must survive discard attempts" + ); + assert!( + committed.checkpoint_cleanup().is_err(), + "fake unsigned new app cannot pass checkpoint" + ); + assert!(committed.backup_path.is_dir()); + } +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/lib.rs b/apps/maple-agent/crates/maple-agent-updater/src/lib.rs new file mode 100644 index 000000000..4f9fde226 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/lib.rs @@ -0,0 +1,18 @@ +//! Agent-only authenticated update discovery, download and installation primitives. +//! Callers must retain installation and host-startup fences around installation. +//! +//! Filesystem security tests target macOS/Linux Unix semantics. Windows CI +//! compiles this crate; Windows application updates are intentionally unsupported. +mod client; +pub mod installer; +pub mod manifest; +mod state; + +pub use client::{CheckOutcome, Config, Revalidation, Updater}; +pub use manifest::{Artifact, Downloads, Manifest, Target, VerifiedRelease}; +pub use semver::Version; +pub use state::PreparedUpdate; +pub use url::Url; + +#[cfg(all(test, unix))] +mod tests; diff --git a/apps/maple-agent/crates/maple-agent-updater/src/manifest.rs b/apps/maple-agent/crates/maple-agent-updater/src/manifest.rs new file mode 100644 index 000000000..673e4c791 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/manifest.rs @@ -0,0 +1,252 @@ +use std::collections::BTreeMap; + +use anyhow::{Context, Result, bail, ensure}; +use minisign_verify::{PublicKey, Signature}; +use semver::Version; +use serde::{Deserialize, Serialize}; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use url::Url; + +pub const MAX_ENVELOPE_BYTES: usize = 128 * 1024; +pub const MAX_MANIFEST_BYTES: usize = 64 * 1024; +pub const MAX_ARTIFACT_BYTES: u64 = 4 * 1024 * 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Target { + DarwinAarch64, + LinuxX86_64, +} + +impl Target { + pub fn key(self) -> &'static str { + match self { + Self::DarwinAarch64 => "darwin-aarch64", + Self::LinuxX86_64 => "linux-x86_64", + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Artifact { + pub path: String, + pub size: u64, + pub sha256: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Downloads { + pub macos: Artifact, + #[serde(rename = "linux-appimage", skip_serializing_if = "Option::is_none")] + pub linux_appimage: Option, +} + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Manifest { + pub format: String, + pub product: String, + pub channel: String, + pub version: String, + pub build: String, + pub source_sha: String, + pub pub_date: String, + pub notes: String, + pub artifacts: BTreeMap, + pub downloads: Downloads, +} + +/// An authenticated manifest and its exact envelope bytes. Public fields allow +/// presentation, but every authority-bearing operation authenticates them again. +#[derive(Clone, Debug)] +pub struct VerifiedRelease { + pub manifest: Manifest, + pub envelope: String, + pub origin: Url, +} + +impl VerifiedRelease { + pub fn version(&self) -> Version { + // Only constructed after schema validation; mutation is rechecked at use. + Version::parse(&self.manifest.version).expect("validated release version") + } + + pub fn artifact(&self, target: Target) -> Result<&Artifact> { + self.manifest + .artifacts + .get(target.key()) + .context("release does not support this target") + } + + pub fn artifact_url(&self, target: Target) -> Result { + Ok(self.origin.join(&self.artifact(target)?.path)?) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Envelope { + manifest: String, + signature: String, +} + +pub fn verify_envelope(bytes: &[u8], origin: &Url, keys: &[String]) -> Result { + ensure!( + bytes.len() <= MAX_ENVELOPE_BYTES, + "update envelope is too large" + ); + let envelope: Envelope = serde_json::from_slice(bytes).context("invalid update envelope")?; + ensure!( + envelope.manifest.len() <= MAX_MANIFEST_BYTES, + "update manifest is too large" + ); + ensure!( + envelope.signature.len() <= 4096, + "update signature is too large" + ); + // Require exactly the four minisign lines. The parser otherwise ignores + // additional lines, which makes malformed publisher output ambiguous. + ensure!( + envelope.signature.lines().count() == 4, + "invalid signature text" + ); + let signature = Signature::decode(&envelope.signature).context("invalid update signature")?; + ensure!( + keys.iter().any(|key| { + let key = if key.starts_with("untrusted comment:") { + PublicKey::decode(key) + } else { + PublicKey::from_base64(key) + }; + key.is_ok_and(|key| { + key.verify(envelope.manifest.as_bytes(), &signature, false) + .is_ok() + }) + }), + "update signature was not made by a pinned key" + ); + // Signature and its trusted comment are authenticated before interpreting + // product, paths, sizes, version or any other manifest field. + let manifest: Manifest = + serde_json::from_str(&envelope.manifest).context("invalid signed manifest")?; + validate_manifest(&manifest)?; + ensure!( + signature.trusted_comment() + == format!( + "product=maple-agent channel=stable version={}", + manifest.version + ), + "signed comment does not match update identity" + ); + Ok(VerifiedRelease { + manifest, + envelope: std::str::from_utf8(bytes)?.to_owned(), + origin: origin.origin().ascii_serialization().parse()?, + }) +} + +pub fn stable_version(value: &str) -> Result { + ensure!(value.len() <= 64, "version is too long"); + let version = Version::parse(value).context("invalid update version")?; + ensure!( + version.pre.is_empty() && version.build.is_empty() && version.to_string() == value, + "update version must be canonical stable SemVer" + ); + Ok(version) +} + +fn lowercase_hex(value: &str, len: usize) -> bool { + value.len() == len + && value + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +pub fn validate_manifest(manifest: &Manifest) -> Result<()> { + ensure!( + manifest.format == "maple-agent-update-v1", + "unsupported update format" + ); + ensure!(manifest.product == "maple-agent", "wrong update product"); + ensure!(manifest.channel == "stable", "wrong update channel"); + stable_version(&manifest.version)?; + ensure!( + !manifest.build.is_empty() + && manifest.build.len() <= 18 + && !manifest.build.starts_with('0') + && manifest.build.bytes().all(|b| b.is_ascii_digit()), + "invalid update build number" + ); + ensure!( + lowercase_hex(&manifest.source_sha, 40), + "invalid update source SHA" + ); + let published = + OffsetDateTime::parse(&manifest.pub_date, &Rfc3339).context("invalid publication time")?; + ensure!( + manifest.pub_date.ends_with('Z') && published.offset().is_utc(), + "publication time must be UTC" + ); + ensure!( + manifest.notes.len() <= 16 * 1024 + && !manifest + .notes + .chars() + .any(|c| c.is_control() && c != '\n' && c != '\t'), + "invalid release notes" + ); + ensure!( + manifest.artifacts.contains_key("darwin-aarch64"), + "macOS artifact is required" + ); + for (target, artifact) in &manifest.artifacts { + let suffix = match target.as_str() { + "darwin-aarch64" => ".app.tar.gz", + "linux-x86_64" => ".AppImage", + _ => bail!("unsupported update target"), + }; + validate_artifact(artifact, &manifest.version, suffix)?; + } + validate_artifact(&manifest.downloads.macos, &manifest.version, ".dmg")?; + ensure!( + manifest.artifacts.contains_key("linux-x86_64") + == manifest.downloads.linux_appimage.is_some(), + "Linux updater and download inventory differ" + ); + if let Some(artifact) = &manifest.downloads.linux_appimage { + validate_artifact(artifact, &manifest.version, ".AppImage")?; + ensure!( + Some(artifact) == manifest.artifacts.get("linux-x86_64"), + "Linux download differs from updater payload" + ); + } + Ok(()) +} + +fn validate_artifact(artifact: &Artifact, version: &str, suffix: &str) -> Result<()> { + ensure!( + artifact.size > 0 && artifact.size <= MAX_ARTIFACT_BYTES, + "invalid artifact size" + ); + ensure!( + lowercase_hex(&artifact.sha256, 64), + "invalid artifact SHA-256" + ); + let prefix = format!("/agent/stable/releases/{version}/"); + let filename = artifact + .path + .strip_prefix(&prefix) + .context("artifact path has wrong release prefix")?; + ensure!( + !filename.is_empty() + && filename.len() <= 200 + && filename.ends_with(suffix) + && !filename.starts_with('.') + && filename + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)), + "invalid artifact filename" + ); + Ok(()) +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/state.rs b/apps/maple-agent/crates/maple-agent-updater/src/state.rs new file mode 100644 index 000000000..1457b05dd --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/state.rs @@ -0,0 +1,297 @@ +use std::{ + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, Result, ensure}; +use fs2::FileExt; +use semver::Version; +use serde::{Deserialize, Serialize}; + +use crate::manifest::{VerifiedRelease, stable_version}; + +const MAX_STATE_BYTES: u64 = 512 * 1024; + +#[derive(Clone, Debug)] +pub struct PreparedUpdate { + pub release: VerifiedRelease, + pub payload_path: PathBuf, + pub installed_path: PathBuf, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct StoredPreparation { + pub envelope: String, + pub origin: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct Record { + format: String, + installed_path: PathBuf, + highest_verified: Option, + prepared: Option, +} + +pub(crate) struct StateStore { + pub root: PathBuf, + installed_path: PathBuf, +} + +impl StateStore { + pub fn new(root: &Path, installed_path: &Path) -> Result { + private_directory(root)?; + let store = Self { + root: root.canonicalize()?, + installed_path: installed_path + .canonicalize() + .context("installed application no longer exists")?, + }; + store.transaction(|_| Ok(()))?; + private_directory(&store.root.join("payloads"))?; + Ok(store) + } + + pub fn installed_path(&self) -> &Path { + &self.installed_path + } + + pub fn highest_verified(&self) -> Result> { + self.transaction(|record| { + record + .highest_verified + .as_deref() + .map(stable_version) + .transpose() + }) + } + + pub fn advance(&self, version: &Version) -> Result<()> { + self.transaction(|record| { + if let Some(highest) = &record.highest_verified { + ensure!( + version >= &stable_version(highest)?, + "update is below highest verified version" + ); + } + record.highest_verified = Some(version.to_string()); + Ok(()) + }) + } + + pub fn prepared(&self) -> Result> { + self.transaction(|record| Ok(record.prepared.clone())) + } + + pub fn save_prepared(&self, release: &VerifiedRelease) -> Result<()> { + self.transaction(|record| { + ensure!( + record + .highest_verified + .as_deref() + .map(stable_version) + .transpose()? + .as_ref() + .is_some_and(|highest| highest == &release.version()), + "preparation was superseded" + ); + record.prepared = Some(StoredPreparation { + envelope: release.envelope.clone(), + origin: release.origin.as_str().to_owned(), + }); + Ok(()) + }) + } + + pub fn take_prepared( + &self, + expected_envelope: Option<&str>, + ) -> Result> { + self.transaction(|record| { + if expected_envelope.is_some_and(|expected| { + record + .prepared + .as_ref() + .is_none_or(|prepared| prepared.envelope != expected) + }) { + return Ok(None); + } + Ok(record.prepared.take()) + }) + } + + pub fn with_commit_guard( + &self, + version: &Version, + envelope: &str, + operation: impl FnOnce() -> Result, + ) -> Result { + let lock = open_private(&self.root.join("state.lock"), false)?; + FileExt::try_lock_exclusive(&lock).context("another update transaction is in progress")?; + let bytes = read_bounded(&self.root.join("state.json"), MAX_STATE_BYTES)? + .context("prepared update state is missing")?; + let record: Record = serde_json::from_slice(&bytes).context("invalid updater state")?; + ensure!( + record.format == "maple-agent-updater-state-v1", + "unsupported updater state" + ); + ensure!( + record.installed_path == self.installed_path, + "preparation belongs to another installation" + ); + ensure!( + record + .highest_verified + .as_deref() + .map(stable_version) + .transpose()? + .as_ref() + .is_none_or(|highest| version >= highest), + "prepared update was superseded by a newer verified release" + ); + ensure!( + record + .prepared + .as_ref() + .is_some_and(|prepared| prepared.envelope == envelope), + "prepared update is no longer current" + ); + // The callback may atomically commit a replacement. Nothing fallible + // can follow it: no serialization, persistence or explicit unlock that + // could incorrectly turn a committed installation into a failure. + // File's RAII close releases the retained lock after the result exists. + operation() + } + + pub fn payload_path(&self, digest: &str, partial: bool) -> PathBuf { + self.root.join("payloads").join(format!( + "{digest}.{}", + if partial { "part" } else { "payload" } + )) + } + + fn transaction(&self, operation: impl FnOnce(&mut Record) -> Result) -> Result { + let lock = open_private(&self.root.join("state.lock"), false)?; + // A commit may retain this lock while replacing an application. Other + // async callers fail busy rather than blocking a Tokio worker thread. + FileExt::try_lock_exclusive(&lock).context("another update transaction is in progress")?; + let path = self.root.join("state.json"); + let mut rebound = false; + let mut record = match read_bounded(&path, MAX_STATE_BYTES)? { + Some(bytes) => { + let mut record: Record = serde_json::from_slice(&bytes) + .context("invalid updater state; refusing to reset rollback protection")?; + ensure!( + record.format == "maple-agent-updater-state-v1", + "unsupported updater state" + ); + if let Some(highest) = &record.highest_verified { + stable_version(highest)?; + } + // Copying a DMG app into /Applications, relocating it, and a + // trusted manual reinstall must not permanently disable the + // updater. Preparation belongs to an installation; rollback + // memory belongs to this profile and survives the move. + if record.installed_path != self.installed_path { + record.installed_path = self.installed_path.clone(); + record.prepared = None; + rebound = true; + } + record + } + None => Record { + format: "maple-agent-updater-state-v1".to_owned(), + installed_path: self.installed_path.clone(), + highest_verified: None, + prepared: None, + }, + }; + let before = serde_json::to_vec(&record)?; + let result = operation(&mut record)?; + let after = serde_json::to_vec(&record)?; + if before != after || rebound { + ensure!( + after.len() <= MAX_STATE_BYTES as usize, + "updater state is too large" + ); + let mut temporary = tempfile::NamedTempFile::new_in(&self.root)?; + temporary.write_all(&after)?; + temporary.as_file().sync_all()?; + temporary + .persist(&path) + .context("cannot persist updater state")?; + File::open(&self.root)?.sync_all()?; + } + FileExt::unlock(&lock)?; + Ok(result) + } +} + +pub(crate) fn private_directory(path: &Path) -> Result<()> { + fs::create_dir_all(path)?; + ensure!( + fs::symlink_metadata(path)?.file_type().is_dir(), + "updater directory is not a real directory" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +pub(crate) fn open_private(path: &Path, truncate: bool) -> Result { + let mut options = OpenOptions::new(); + options + .read(true) + .write(true) + .create(true) + .truncate(truncate); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = options.open(path)?; + ensure!( + file.metadata()?.file_type().is_file(), + "updater file is not regular" + ); + Ok(file) +} + +pub(crate) fn read_bounded(path: &Path, maximum: u64) -> Result>> { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC); + } + let file = match options.open(path) { + Ok(file) => file, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(error.into()), + }; + ensure!( + file.metadata()?.is_file(), + "updater state is not a regular file" + ); + ensure!( + file.metadata()?.len() <= maximum, + "updater state is too large" + ); + let mut bytes = Vec::new(); + file.take(maximum + 1).read_to_end(&mut bytes)?; + ensure!( + bytes.len() <= maximum as usize, + "updater state is too large" + ); + Ok(Some(bytes)) +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/tests.rs b/apps/maple-agent/crates/maple-agent-updater/src/tests.rs new file mode 100644 index 000000000..b2c6e4483 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/tests.rs @@ -0,0 +1,731 @@ +use std::{ + collections::BTreeMap, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use base64::{Engine, engine::general_purpose::STANDARD}; +use blake2::{Blake2b512, Digest}; +use ed25519_dalek::{Signer, SigningKey}; +use serde_json::json; +use sha2::Sha256; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::{ + Artifact, CheckOutcome, Config, Downloads, Manifest, Revalidation, Target, Version, + client::test_updater, + manifest::{self, verify_envelope}, +}; + +const PAYLOAD: &[u8] = b"signed payload fixture bytes"; + +fn key(seed: u8) -> String { + let signing = SigningKey::from_bytes(&[seed; 32]); + let mut bytes = b"Ed12345678".to_vec(); + bytes.extend_from_slice(signing.verifying_key().as_bytes()); + STANDARD.encode(bytes) +} + +fn sign_text(text: &str, comment: &str, seed: u8) -> Vec { + // Deterministic test-only key, never an application pin or production key. + let signing = SigningKey::from_bytes(&[seed; 32]); + let message_signature = signing.sign(&Blake2b512::digest(text.as_bytes())); + let mut packet = b"ED12345678".to_vec(); + packet.extend_from_slice(&message_signature.to_bytes()); + let mut global_message = message_signature.to_bytes().to_vec(); + global_message.extend_from_slice(comment.as_bytes()); + let global_signature = signing.sign(&global_message); + serde_json::to_vec(&json!({ + "manifest": text, + "signature": format!("untrusted comment: TEST ONLY\n{}\ntrusted comment: {comment}\n{}\n", STANDARD.encode(packet), STANDARD.encode(global_signature.to_bytes())) + })).unwrap() +} + +fn envelope(manifest: &Manifest) -> Vec { + sign_text( + &serde_json::to_string(manifest).unwrap(), + &format!( + "product=maple-agent channel=stable version={}", + manifest.version + ), + 7, + ) +} + +fn artifact(version: &str, filename: &str) -> Artifact { + Artifact { + path: format!("/agent/stable/releases/{version}/{filename}"), + size: PAYLOAD.len() as u64, + sha256: hex::encode(Sha256::digest(PAYLOAD)), + } +} + +fn manifest(version: &str) -> Manifest { + Manifest { + format: "maple-agent-update-v1".into(), + product: "maple-agent".into(), + channel: "stable".into(), + version: version.into(), + build: "2".into(), + source_sha: "a".repeat(40), + pub_date: "2026-10-21T15:00:00Z".into(), + notes: "A test update.\nPlain text.".into(), + artifacts: BTreeMap::from([( + "darwin-aarch64".into(), + artifact(version, "Maple-Agent.app.tar.gz"), + )]), + downloads: Downloads { + macos: artifact(version, "Maple-Agent.dmg"), + linux_appimage: None, + }, + } +} + +fn origin() -> crate::Url { + "https://updates.example.test".parse().unwrap() +} + +#[test] +fn static_public_publisher_fixture_uses_the_same_authenticated_contract() { + let release = verify_envelope( + include_bytes!("../tests/fixtures/signed-envelope.json"), + &origin(), + &[include_str!("../tests/fixtures/public-test-key.pub").to_owned()], + ) + .unwrap(); + assert_eq!(release.manifest, manifest("0.0.2")); + assert_eq!( + release.manifest.artifacts["darwin-aarch64"].sha256, + hex::encode(Sha256::digest(PAYLOAD)) + ); +} + +#[test] +fn exact_bytes_and_authenticated_comment_are_required() { + let manifest = manifest("0.0.2"); + let bytes = envelope(&manifest); + assert_eq!( + verify_envelope(&bytes, &origin(), &[key(7)]) + .unwrap() + .manifest, + manifest + ); + let mut altered: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + altered["manifest"] = json!(format!("{} ", altered["manifest"].as_str().unwrap())); + assert!(verify_envelope(&serde_json::to_vec(&altered).unwrap(), &origin(), &[key(7)]).is_err()); + let wrong_comment = sign_text( + &serde_json::to_string(&manifest).unwrap(), + "product=maple-agent channel=stable version=0.0.3", + 7, + ); + assert!(verify_envelope(&wrong_comment, &origin(), &[key(7)]).is_err()); +} + +#[test] +fn primary_or_backup_key_must_verify_before_fields_are_parsed() { + assert!(verify_envelope(&envelope(&manifest("0.0.2")), &origin(), &[key(8)]).is_err()); + assert!(verify_envelope(&envelope(&manifest("0.0.2")), &origin(), &[key(8), key(7)]).is_ok()); + let invalid = sign_text("this is not JSON", "irrelevant", 8); + assert!( + verify_envelope(&invalid, &origin(), &[key(7)]) + .unwrap_err() + .to_string() + .contains("pinned key") + ); +} + +#[test] +fn schema_identity_and_supported_inventory_are_strict() { + let mutations: Vec = vec![ + |m| m.format = "future".into(), + |m| m.product = "research".into(), + |m| m.channel = "dev".into(), + |m| m.version = "0.0.2-beta.1".into(), + |m| m.version = "0.0.2+build".into(), + |m| m.build = "02".into(), + |m| m.build = "2.0".into(), + |m| m.source_sha = "A".repeat(40), + |m| m.pub_date = "2026-10-21T15:00:00+00:00".into(), + |m| m.notes = "bad\u{0}".into(), + |m| { + m.artifacts.remove("darwin-aarch64"); + }, + |m| { + m.artifacts + .insert("windows-x86_64".into(), artifact("0.0.2", "x.exe")); + }, + |m| m.downloads.linux_appimage = Some(artifact("0.0.2", "x.AppImage")), + ]; + for mutation in mutations { + let mut value = manifest("0.0.2"); + mutation(&mut value); + assert!( + verify_envelope(&envelope(&value), &origin(), &[key(7)]).is_err(), + "accepted {value:?}" + ); + } +} + +#[test] +fn paths_sizes_and_digests_are_bound_to_exact_version_and_target() { + for path in [ + "/agent/stable/releases/0.0.1/x.app.tar.gz", + "//evil.test/x.app.tar.gz", + "https://evil.test/x.app.tar.gz", + "/agent/stable/releases/0.0.2/../x.app.tar.gz", + "/agent/stable/releases/0.0.2/%2e%2e.app.tar.gz", + "/agent/stable/releases/0.0.2/x.app.tar.gz?x", + "/agent/stable/releases/0.0.2/x.app.tar.gz#x", + "/agent/stable/releases/0.0.2/.hidden.app.tar.gz", + "/agent/stable/releases/0.0.2/x.dmg", + ] { + let mut value = manifest("0.0.2"); + value.artifacts.get_mut("darwin-aarch64").unwrap().path = path.into(); + assert!( + verify_envelope(&envelope(&value), &origin(), &[key(7)]).is_err(), + "accepted {path}" + ); + } + for size in [0, manifest::MAX_ARTIFACT_BYTES + 1] { + let mut value = manifest("0.0.2"); + value.artifacts.get_mut("darwin-aarch64").unwrap().size = size; + assert!(verify_envelope(&envelope(&value), &origin(), &[key(7)]).is_err()); + } + let mut value = manifest("0.0.2"); + value.artifacts.get_mut("darwin-aarch64").unwrap().sha256 = "A".repeat(64); + assert!(verify_envelope(&envelope(&value), &origin(), &[key(7)]).is_err()); +} + +#[test] +fn oversized_unknown_and_duplicate_envelope_fields_are_rejected() { + assert!( + verify_envelope( + &vec![b' '; manifest::MAX_ENVELOPE_BYTES + 1], + &origin(), + &[key(7)] + ) + .is_err() + ); + let mut value: serde_json::Value = + serde_json::from_slice(&envelope(&manifest("0.0.2"))).unwrap(); + value["unknown"] = json!(true); + assert!(verify_envelope(&serde_json::to_vec(&value).unwrap(), &origin(), &[key(7)]).is_err()); + assert!( + verify_envelope( + br#"{"manifest":"a","manifest":"b","signature":"x"}"#, + &origin(), + &[key(7)] + ) + .is_err() + ); +} + +struct Fixture { + url: crate::Url, + requests: Arc>>, + task: tokio::task::JoinHandle<()>, +} + +impl Fixture { + async fn new(responses: Vec>) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!( + "http://{}/agent/stable/latest.json", + listener.local_addr().unwrap() + ) + .parse() + .unwrap(); + let requests = Arc::new(Mutex::new(Vec::new())); + let observed = requests.clone(); + let task = tokio::spawn(async move { + for response in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + while !request.ends_with(b"\r\n\r\n") { + let mut byte = [0]; + if stream.read(&mut byte).await.unwrap() == 0 { + break; + } + request.extend_from_slice(&byte); + assert!(request.len() < 8192); + } + observed + .lock() + .unwrap() + .push(String::from_utf8(request).unwrap()); + stream.write_all(&response).await.unwrap(); + stream.shutdown().await.unwrap(); + } + }); + Self { + url, + requests, + task, + } + } + + fn config(&self, temp: &tempfile::TempDir) -> Config { + let installed = temp.path().join("Maple Agent.app"); + std::fs::create_dir_all(&installed).unwrap(); + Config { + feed_url: self.url.clone(), + public_keys: vec![key(7)], + current_version: Version::new(0, 0, 1), + target: Target::DarwinAarch64, + installed_path: installed, + state_dir: temp.path().join("updates"), + } + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + self.task.abort(); + } +} + +fn response(status: &str, body: &[u8], headers: &str) -> Vec { + let mut bytes = format!("HTTP/1.1 {status}\r\nConnection: close\r\n{headers}\r\n").into_bytes(); + bytes.extend_from_slice(body); + bytes +} + +fn ok(body: &[u8]) -> Vec { + response( + "200 OK", + body, + &format!("Content-Length: {}\r\n", body.len()), + ) +} + +async fn available(updater: &crate::Updater) -> crate::VerifiedRelease { + let CheckOutcome::Available(release) = updater.check().await.unwrap() else { + panic!("expected update") + }; + *release +} + +#[tokio::test] +async fn downloads_and_reauthenticates_preparation_across_restart() { + let signed = envelope(&manifest("0.0.2")); + let server = Fixture::new(vec![ok(&signed), ok(PAYLOAD), ok(&signed)]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let release = available(&updater).await; + let prepared = updater.download(&release).await.unwrap(); + assert_eq!(std::fs::read(&prepared.payload_path).unwrap(), PAYLOAD); + drop(updater); + let updater = test_updater(server.config(&temp)).unwrap(); + let restored = updater.restore_prepared().await.unwrap().unwrap(); + assert!(matches!( + updater.revalidate(&restored).await.unwrap(), + Revalidation::Ready(_) + )); + let requests = server.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert!(requests.iter().all(|request| { + request + .to_ascii_lowercase() + .contains("user-agent: maple-agent-updater/1") + })); + assert!( + requests + .iter() + .all(|request| !request.to_ascii_lowercase().contains("authorization:")) + ); +} + +#[tokio::test] +async fn rollback_clears_offer_without_lowering_persisted_high_watermark() { + let server = Fixture::new(vec![ + ok(&envelope(&manifest("0.0.3"))), + ok(PAYLOAD), + ok(&envelope(&manifest("0.0.2"))), + ]) + .await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + assert!(matches!( + updater.revalidate(&prepared).await.unwrap(), + Revalidation::Withdrawn + )); + assert!(updater.restore_prepared().await.unwrap().is_none()); + let record = std::fs::read_to_string(temp.path().join("updates/state.json")).unwrap(); + assert!(record.contains("\"highest_verified\":\"0.0.3\"")); +} + +#[tokio::test] +async fn newer_pointer_is_returned_immediately_to_schedule_download() { + let server = Fixture::new(vec![ + ok(&envelope(&manifest("0.0.2"))), + ok(PAYLOAD), + ok(&envelope(&manifest("0.0.3"))), + ]) + .await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + let Revalidation::Superseded(newer) = updater.revalidate(&prepared).await.unwrap() else { + panic!("expected newer") + }; + assert_eq!(newer.version(), Version::new(0, 0, 3)); + assert!(updater.restore_prepared().await.unwrap().is_none()); +} + +#[tokio::test] +async fn discovery_failure_keeps_preparation_for_retry() { + let server = Fixture::new(vec![ + ok(&envelope(&manifest("0.0.2"))), + ok(PAYLOAD), + response("503 Unavailable", b"", "Content-Length: 0\r\n"), + ]) + .await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + assert!(updater.revalidate(&prepared).await.is_err()); + assert!(updater.restore_prepared().await.unwrap().is_some()); +} + +#[tokio::test] +async fn tampered_payload_and_saved_destination_never_restore_ready() { + let server = Fixture::new(vec![ok(&envelope(&manifest("0.0.2"))), ok(PAYLOAD)]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + std::fs::write(&prepared.payload_path, vec![b'x'; PAYLOAD.len()]).unwrap(); + assert!(updater.restore_prepared().await.is_err()); + assert!(updater.restore_prepared().await.unwrap().is_none()); + let mut config = server.config(&temp); + config.installed_path = temp.path().join("Other.app"); + std::fs::create_dir_all(&config.installed_path).unwrap(); + let moved = test_updater(config).unwrap(); + assert!(moved.restore_prepared().await.unwrap().is_none()); + let state = std::fs::read_to_string(temp.path().join("updates/state.json")).unwrap(); + assert!(state.contains("\"highest_verified\":\"0.0.2\"")); +} + +#[tokio::test] +async fn resumes_only_exact_valid_ranges_and_final_digest() { + let signed = envelope(&manifest("0.0.2")); + let server = Fixture::new(vec![ + ok(&signed), + response( + "206 Partial Content", + &PAYLOAD[6..], + &format!( + "Content-Length: {}\r\nContent-Range: bytes 6-{}/{}\r\n", + PAYLOAD.len() - 6, + PAYLOAD.len() - 1, + PAYLOAD.len() + ), + ), + ]) + .await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let release = available(&updater).await; + let partial = temp.path().join("updates/payloads").join(format!( + "{}.part", + release.artifact(Target::DarwinAarch64).unwrap().sha256 + )); + std::fs::write(partial, &PAYLOAD[..6]).unwrap(); + updater.download(&release).await.unwrap(); + assert!(server.requests.lock().unwrap()[1].contains("range: bytes=6-")); +} + +#[tokio::test] +async fn ignored_range_restarts_instead_of_appending() { + let signed = envelope(&manifest("0.0.2")); + let server = Fixture::new(vec![ok(&signed), ok(PAYLOAD)]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let release = available(&updater).await; + let partial = temp.path().join("updates/payloads").join(format!( + "{}.part", + release.artifact(Target::DarwinAarch64).unwrap().sha256 + )); + std::fs::write(partial, &PAYLOAD[..6]).unwrap(); + assert_eq!( + std::fs::read(updater.download(&release).await.unwrap().payload_path).unwrap(), + PAYLOAD + ); +} + +#[tokio::test] +async fn interrupted_transfer_preserves_a_resumable_verified_prefix() { + let signed = envelope(&manifest("0.0.2")); + let server = Fixture::new(vec![ + ok(&signed), + response( + "200 OK", + &PAYLOAD[..6], + &format!("Content-Length: {}\r\n", PAYLOAD.len()), + ), + response( + "206 Partial Content", + &PAYLOAD[6..], + &format!( + "Content-Length: {}\r\nContent-Range: bytes 6-{}/{}\r\n", + PAYLOAD.len() - 6, + PAYLOAD.len() - 1, + PAYLOAD.len() + ), + ), + ]) + .await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let release = available(&updater).await; + assert!(updater.download(&release).await.is_err()); + let prepared = updater.download(&release).await.unwrap(); + assert_eq!(std::fs::read(prepared.payload_path).unwrap(), PAYLOAD); + assert!(server.requests.lock().unwrap()[2].contains("range: bytes=6-")); +} + +#[tokio::test] +async fn invalid_range_oversized_and_truncated_payloads_fail_closed() { + let bad_responses = vec![ + response( + "206 Partial Content", + &PAYLOAD[6..], + &format!( + "Content-Range: bytes 0-{}/{}\r\n", + PAYLOAD.len() - 1, + PAYLOAD.len() + ), + ), + response("200 OK", &[PAYLOAD, b"overrun"].concat(), ""), + ok(&PAYLOAD[..6]), + ok(&vec![b'x'; PAYLOAD.len()]), + ]; + for bad in bad_responses { + let server = Fixture::new(vec![ok(&envelope(&manifest("0.0.2"))), bad]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let release = available(&updater).await; + assert!(updater.download(&release).await.is_err()); + assert!(updater.restore_prepared().await.unwrap().is_none()); + } +} + +#[tokio::test] +async fn production_constructor_and_redirects_reject_unsafe_urls() { + let temp = tempfile::tempdir().unwrap(); + let server = Fixture::new(vec![response( + "302 Found", + b"", + "Location: https://evil.test/payload\r\nContent-Length: 0\r\n", + )]) + .await; + assert!(crate::Updater::new(server.config(&temp)).is_err()); + let updater = test_updater(server.config(&temp)).unwrap(); + assert!(updater.check().await.is_err()); + for url in [ + "https://name:password@example.test/feed", + "https://example.test/feed?token=x", + "https://example.test/feed#x", + ] { + let mut config = server.config(&temp); + config.feed_url = url.parse().unwrap(); + assert!(crate::Updater::new(config).is_err()); + } +} + +#[tokio::test] +async fn redirect_loops_and_oversized_metadata_are_bounded() { + let server = Fixture::new( + (0..5) + .map(|_| { + response( + "302 Found", + b"", + "Location: /again\r\nContent-Length: 0\r\n", + ) + }) + .collect(), + ) + .await; + let temp = tempfile::tempdir().unwrap(); + assert!( + test_updater(server.config(&temp)) + .unwrap() + .check() + .await + .is_err() + ); + assert_eq!(server.requests.lock().unwrap().len(), 5); + let server = Fixture::new(vec![response( + "200 OK", + b"", + "Content-Length: 999999999\r\n", + )]) + .await; + assert!( + test_updater(server.config(&temp)) + .unwrap() + .check() + .await + .is_err() + ); +} + +#[tokio::test] +async fn absent_linux_does_not_invalidate_macos_and_offers_no_linux_update() { + let signed = envelope(&manifest("0.0.2")); + assert!(verify_envelope(&signed, &origin(), &[key(7)]).is_ok()); + let server = Fixture::new(vec![ok(&signed)]).await; + let temp = tempfile::tempdir().unwrap(); + let mut config = server.config(&temp); + config.target = Target::LinuxX86_64; + assert!(matches!( + test_updater(config).unwrap().check().await.unwrap(), + CheckOutcome::Current + )); +} + +#[test] +fn only_the_two_compiled_origins_allow_cross_origin_migration() { + let allowed = |from: &str, to: &str| { + crate::client::origin_allowed(&from.parse().unwrap(), &to.parse().unwrap()) + }; + assert!(allowed( + "https://updates.trymaple.ai/agent/stable/latest.json", + "https://updates.maple.ai/agent/stable/latest.json" + )); + assert!(allowed( + "https://updates.maple.ai/agent/stable/latest.json", + "https://updates.trymaple.ai/agent/stable/latest.json" + )); + for target in [ + "https://other.maple.ai/file", + "https://evil.test/file", + "https://updates.maple.ai:444/file", + "http://updates.maple.ai/file", + ] { + assert!(!allowed("https://updates.trymaple.ai/feed", target)); + } + assert!(allowed( + "https://example.test:444/feed", + "https://example.test:444/file" + )); + assert!(!allowed( + "https://example.test:444/feed", + "https://example.test/file" + )); + assert!(!allowed( + "https://example.test/feed", + "https://updates.maple.ai/file" + )); +} + +#[tokio::test] +async fn moved_installation_discards_preparation_and_retains_rollback_memory() { + let server = Fixture::new(vec![ + ok(&envelope(&manifest("0.0.3"))), + ok(PAYLOAD), + ok(&envelope(&manifest("0.0.2"))), + ]) + .await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + updater.download(&available(&updater).await).await.unwrap(); + drop(updater); + let mut config = server.config(&temp); + config.installed_path = temp.path().join("Moved Agent.app"); + std::fs::create_dir_all(&config.installed_path).unwrap(); + let updater = test_updater(config).unwrap(); + assert!(updater.restore_prepared().await.unwrap().is_none()); + assert!(matches!( + updater.check().await.unwrap(), + CheckOutcome::Current + )); + let record = std::fs::read_to_string(temp.path().join("updates/state.json")).unwrap(); + assert!(record.contains("\"highest_verified\":\"0.0.3\"")); + assert!(record.contains("Moved Agent.app")); +} + +#[tokio::test] +async fn newer_process_verification_between_ready_and_commit_blocks_old_installation() { + let signed = envelope(&manifest("0.0.2")); + let server = Fixture::new(vec![ + ok(&signed), + ok(PAYLOAD), + ok(&signed), + ok(&envelope(&manifest("0.0.3"))), + ]) + .await; + let temp = tempfile::tempdir().unwrap(); + let first = test_updater(server.config(&temp)).unwrap(); + let second = test_updater(server.config(&temp)).unwrap(); + let prepared = first.download(&available(&first).await).await.unwrap(); + assert!(matches!( + first.revalidate(&prepared).await.unwrap(), + Revalidation::Ready(_) + )); + assert_eq!(available(&second).await.version(), Version::new(0, 0, 3)); + let installed = std::cell::Cell::new(false); + assert!( + first + .with_commit_guard(&prepared, || { + installed.set(true); + Ok(()) + }) + .is_err() + ); + assert!(!installed.get()); +} + +#[tokio::test] +async fn commit_guard_excludes_competing_advancement_without_blocking() { + let signed = envelope(&manifest("0.0.2")); + let server = Fixture::new(vec![ok(&signed), ok(PAYLOAD)]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + let competing = + crate::state::StateStore::new(&temp.path().join("updates"), &prepared.installed_path) + .unwrap(); + updater + .with_commit_guard(&prepared, || { + assert!(competing.advance(&Version::new(0, 0, 3)).is_err()); + Ok(()) + }) + .unwrap(); + competing.advance(&Version::new(0, 0, 3)).unwrap(); + assert!(updater.with_commit_guard(&prepared, || Ok(())).is_err()); +} + +#[tokio::test] +async fn successful_commit_result_survives_loss_of_state_directory_after_replacement() { + let server = Fixture::new(vec![ok(&envelope(&manifest("0.0.2"))), ok(PAYLOAD)]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + let result = updater + .with_commit_guard(&prepared, || { + // Model an unrelated filesystem failure after replacement. Returning + // the committed outcome cannot depend on any later state-directory IO. + std::fs::remove_dir_all(temp.path().join("updates")).map_err(|e| e.to_string())?; + Ok("committed") + }) + .unwrap(); + assert_eq!(result, "committed"); +} + +#[cfg(unix)] +#[tokio::test] +async fn saved_payload_symlinks_are_never_followed() { + use std::os::unix::fs::symlink; + let server = Fixture::new(vec![ok(&envelope(&manifest("0.0.2"))), ok(PAYLOAD)]).await; + let temp = tempfile::tempdir().unwrap(); + let updater = test_updater(server.config(&temp)).unwrap(); + let prepared = updater.download(&available(&updater).await).await.unwrap(); + let outside: PathBuf = temp.path().join("outside"); + std::fs::write(&outside, PAYLOAD).unwrap(); + std::fs::remove_file(&prepared.payload_path).unwrap(); + symlink(&outside, &prepared.payload_path).unwrap(); + assert!(updater.restore_prepared().await.is_err()); + assert_eq!(std::fs::read(outside).unwrap(), PAYLOAD); +} diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/README.md b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/README.md new file mode 100644 index 000000000..8995dbb52 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/README.md @@ -0,0 +1,8 @@ +These are public test fixtures, signed with the deterministic test-only seed +`[7; 32]` also used by `src/tests.rs`. This key is public knowledge and must never +be pinned in an application profile or used to sign a production release. + +`manifest.json` contains the exact bytes authenticated by `manifest.minisig`. +`signed-envelope.json` embeds the same bytes and signature for client, Worker +and publisher contract tests. The artifact bytes are the ASCII string +`signed payload fixture bytes`; they are not an application archive. diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/manifest.json b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/manifest.json new file mode 100644 index 000000000..62fc084c9 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/manifest.json @@ -0,0 +1 @@ +{"format":"maple-agent-update-v1","product":"maple-agent","channel":"stable","version":"0.0.2","build":"2","source_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","pub_date":"2026-10-21T15:00:00Z","notes":"A test update.\nPlain text.","artifacts":{"darwin-aarch64":{"path":"/agent/stable/releases/0.0.2/Maple-Agent.app.tar.gz","size":28,"sha256":"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da"}},"downloads":{"macos":{"path":"/agent/stable/releases/0.0.2/Maple-Agent.dmg","size":28,"sha256":"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da"}}} \ No newline at end of file diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/manifest.minisig b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/manifest.minisig new file mode 100644 index 000000000..a2ef4d8c8 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/manifest.minisig @@ -0,0 +1,4 @@ +untrusted comment: TEST ONLY +RUQxMjM0NTY3OCUVO3EkRz9emd4Vq7qd4IR+x9DelvoFJkcVwb/q0ODwQHQxK7yxsqUtoOA++iMkjIiVg3y+EKA964mP5WQwdw0= +trusted comment: product=maple-agent channel=stable version=0.0.2 +4Mo/xlTkXa0LMc4N+SJyNbTqErz0CLZg4vQMSCz4KzR1MdAITbAmXq9Vy4aXRhSXtFoeuTBSe3w4IF8I+aMXDQ== diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/public-test-key.pub b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/public-test-key.pub new file mode 100644 index 000000000..9d4de7dda --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/public-test-key.pub @@ -0,0 +1,2 @@ +untrusted comment: PUBLIC TEST KEY; NEVER SHIP +RWQxMjM0NTY3OOpKbGPinFIKvvVQexMuxfmVR3auvr57kkIe6mkURtIs diff --git a/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-envelope.json b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-envelope.json new file mode 100644 index 000000000..385f99a0f --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/tests/fixtures/signed-envelope.json @@ -0,0 +1,4 @@ +{ + "manifest": "{\"format\":\"maple-agent-update-v1\",\"product\":\"maple-agent\",\"channel\":\"stable\",\"version\":\"0.0.2\",\"build\":\"2\",\"source_sha\":\"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\",\"pub_date\":\"2026-10-21T15:00:00Z\",\"notes\":\"A test update.\\nPlain text.\",\"artifacts\":{\"darwin-aarch64\":{\"path\":\"/agent/stable/releases/0.0.2/Maple-Agent.app.tar.gz\",\"size\":28,\"sha256\":\"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da\"}},\"downloads\":{\"macos\":{\"path\":\"/agent/stable/releases/0.0.2/Maple-Agent.dmg\",\"size\":28,\"sha256\":\"106cfb1b4d93e07f8c02c2a3440aafd7dd2f5b0889bbb7ba77bc99ca85ef84da\"}}}", + "signature": "untrusted comment: TEST ONLY\nRUQxMjM0NTY3OCUVO3EkRz9emd4Vq7qd4IR+x9DelvoFJkcVwb/q0ODwQHQxK7yxsqUtoOA++iMkjIiVg3y+EKA964mP5WQwdw0=\ntrusted comment: product=maple-agent channel=stable version=0.0.2\n4Mo/xlTkXa0LMc4N+SJyNbTqErz0CLZg4vQMSCz4KzR1MdAITbAmXq9Vy4aXRhSXtFoeuTBSe3w4IF8I+aMXDQ==\n" +} diff --git a/apps/maple-agent/flake.nix b/apps/maple-agent/flake.nix index ead7cadfe..fb8f0319e 100644 --- a/apps/maple-agent/flake.nix +++ b/apps/maple-agent/flake.nix @@ -72,7 +72,7 @@ { default = rustPlatform.buildRustPackage { pname = "maple-agent"; - version = "0.1.0"; + version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version; src = pkgs.lib.fileset.toSource { root = ../..; From c6346be5859c6d5a4077a47d187124e35542d043 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 04:42:01 +0000 Subject: [PATCH 04/12] feat(agent): add explicit Prod update and restart flow --- .agents/skills/develop-maple-agent/SKILL.md | 22 +- .github/workflows/agent-desktop-build.yml | 2 +- apps/maple-agent/README.md | 30 +- apps/maple-agent/app/Cargo.toml | 1 + apps/maple-agent/app/build.rs | 2 + apps/maple-agent/app/src/desktop.rs | 308 ++++- apps/maple-agent/app/src/host.rs | 175 +++ apps/maple-agent/app/src/profile.rs | 10 + apps/maple-agent/app/src/settings.rs | 22 + apps/maple-agent/app/src/tray.rs | 5 +- apps/maple-agent/app/src/ui/chat/mod.rs | 72 +- apps/maple-agent/app/src/ui/menus.rs | 10 + apps/maple-agent/app/src/ui/settings.rs | 51 +- apps/maple-agent/app/src/update.rs | 1093 +++++++++++------ apps/maple-agent/docs/desktop-builds.md | 29 +- apps/maple-agent/docs/release-profiles.md | 33 +- apps/maple-agent/docs/updates.md | 149 +++ apps/maple-agent/release-profiles.json | 4 + apps/maple-agent/release_profile.rs | 94 +- apps/maple-agent/scripts/release-info.py | 33 +- .../scripts/test_release_packaging.py | 25 + docs/app-signing-environments.md | 8 +- scripts/ci/test_agent_workflows.py | 2 +- scripts/ci/test_signing_workflows.py | 7 +- 24 files changed, 1658 insertions(+), 529 deletions(-) create mode 100644 apps/maple-agent/docs/updates.md diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 9d3ca2bd3..c9a5830eb 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -98,14 +98,18 @@ account isolation, tool execution, MCP/ACP inputs, persistence, and process ownership at the layer implementing the effect. Never treat a passing source import or a native login screen as authenticated chat or containment proof. -Packaged Agent Prod only links to stable `maple-agent-vX.Y.Z` releases; Dev only -links to prereleases named `maple-agent-dev-vX.Y.Z`. Their embedded profiles, -bundle IDs, and configuration/data namespaces must remain separate. Packaged -profiles never adopt legacy Agent or Research state. See +Packaged Agent Prod uses its independently signed R2 update feed with compiled +public-key pins. Dev and unpackaged builds have no production updater. Empty +production pins disable update traffic until reviewed setup; do not substitute +a test key or inherit feed/key overrides from the launch environment. Embedded +profiles, bundle IDs and state namespaces remain separate; packaged profiles +never adopt legacy Agent or Research state. See [`desktop builds`](../../../apps/maple-agent/docs/desktop-builds.md) for packaging commands and the protected-master signing/artifact contract. -Never use repository-wide `/releases/latest` for Agent, accept Research's bare -`vX.Y.Z` tags, or turn a failed/incomplete release scan into an update offer. -No Agent publisher is activated by the import. Future Agent release work -requires explicit authorization and `make_latest: false` to preserve Research's -latest pointer. Do not rename SDKs or publish registries incidentally. +Never use repository-wide `/releases/latest` for Agent or modify Research's +Worker/feed/publisher while working on the Agent updater. Preserve exact signed +manifest bytes, anti-rollback, bounded downloads, staged-state revalidation and +host/install lock ownership. Linux activation needs its own packaged update proof. +Follow [updater development](../../../apps/maple-agent/docs/updates.md) for key +setup and validation. Production resources, keys, routes, signing and publication +remain separately authorized operations. Do not publish registries incidentally. diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index 86863473d..4b75cb89c 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -127,7 +127,7 @@ jobs: name: Signed macOS (${{ matrix.variant }}) needs: [changes, macos-build] if: ${{ always() && !cancelled() && needs.macos-build.result == 'success' && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - environment: desktop-signing + environment: agent-desktop-signing permissions: contents: read id-token: write diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 17bbb456f..5ddd3dd2c 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -486,7 +486,7 @@ the missing feature. Local unpackaged builds accept the environment settings below. Packaged Dev and Prod bake their service endpoints, client ID, PCR trust, state namespace, -and update repository at compile time; service and repository overrides are +and updater trust at compile time; service overrides are ignored. See [build profiles](docs/release-profiles.md). | Variable | Purpose | Default | @@ -497,8 +497,7 @@ ignored. See [build profiles](docs/release-profiles.md). | `MAPLE_MODEL` | Model to select at start. | Runtime default | | `MAPLE_CONTEXT_LIMIT` | Context window in tokens, used instead of the model catalog's for compaction and the context ring. | Catalog value | | `MAPLE_SHELL` | The bash the agent's `bash` tool runs. | `/bin/bash`, else `bash` on PATH (Windows: Git Bash; without it, the `powershell` tool) | -| `MAPLE_UPDATE_REPO` | GitHub `owner/repo` containing stable `maple-agent-vX.Y.Z` releases. | `MaplePrivacyLabs/Maple` | -| `MAPLE_DISABLE_UPDATE_CHECK` | `1` turns the release check off. | unset | +| `MAPLE_DISABLE_UPDATE_CHECK` | `1` turns Agent update checks off. | unset | | `RUST_LOG` | Log filter. | `info` | ### File locations @@ -563,17 +562,24 @@ check. ## Update and release boundary -Prod and unpackaged builds accept stable `maple-agent-vX.Y.Z` releases. Dev -accepts only prereleases in `maple-agent-dev-vX.Y.Z`. Each selects the highest -semantic version across a bounded, complete scan of release pages. Research -`vX.Y.Z` releases, drafts, and the other channel are ignored. A failed or -incomplete scan produces no update banner. The app only links to a canonical -GitHub release page; it does not download or install binaries. +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 public keys; inherited environment variables cannot replace +them. Until approved production keys are pinned, the UI reports that updates +are not configured and makes no update requests. + +Updates authenticate product, channel, version, target, byte size and SHA-256 +before staging. Automatic downloads default on; installation requires the +explicit Restart to Update action, fresh feed revalidation and profile-host +shutdown. Read-only installations offer a manual download. Linux automatic +replacement stays disabled until its packaged update gate is independently +validated. See [updater development and activation](docs/updates.md). The [desktop build workflow](docs/desktop-builds.md) provides Actions artifacts -without creating releases or advancing update feeds. Future Agent releases -must set `make_latest: false` so Research’s global GitHub latest pointer remains -unchanged. Release publication requires its own authorization. Local +without creating releases or advancing update feeds. Agent publication uses +its own R2 bucket and workflow, with no GitHub Release or tag creation and no +Research Worker/feed changes. Production activation and release publication +require their own authorization. Local `just release` and `just dist` only build local files. ## Shared dependencies and provenance diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 6c64efa6b..b8b64cd55 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -37,6 +37,7 @@ proxy = ["dep:maple-proxy", "dep:axum", "dep:tower-http"] dirs = "6" maple-agent = { path = "../crates/maple-agent", default-features = false } maple-billing = { path = "../crates/maple-billing" } +maple-agent-updater = { path = "../crates/maple-agent-updater" } # gpui is pinned to the Zed commit that gpui-libghostty (the embedded # terminal Ben is preparing) builds against, so the two agree on one gpui # once the terminal lands. crates.io 0.2.2 is older than this commit. diff --git a/apps/maple-agent/app/build.rs b/apps/maple-agent/app/build.rs index 0bb1063f7..f9d349dd4 100644 --- a/apps/maple-agent/app/build.rs +++ b/apps/maple-agent/app/build.rs @@ -68,6 +68,8 @@ fn main() { "git_revision": revision, "source_sha": source_sha, "update_tag_prefix": profile.update_tag_prefix, + "update_feed_url": profile.update_feed_url, + "update_public_keys": profile.update_public_keys, "prerelease": profile.prerelease, }); // GPUI unifies serde_json's preserve_order feature in desktop builds. diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 9d742de01..8ae98cc53 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -101,6 +101,8 @@ struct MapleApp { tray: Option, remote_bridges: std::cell::RefCell>>, settings: crate::settings::AppSettings, + updater: crate::update::Controller, + restart_prompt_pending: bool, /// The complete live shortcut map. Settings mutations prepare a full /// replacement before this runtime clears GPUI's process-wide bindings. shortcuts: crate::shortcuts::ShortcutRuntime, @@ -147,18 +149,107 @@ impl MapleApp { } } - /// A newer release exists: tell the chat screen so it shows the banner. - /// Without a chat screen (login in progress) nothing is done here; the - /// result stays in `update::available()` and seeds the next chat. - fn set_update(&mut self, info: crate::update::UpdateInfo, cx: &mut Context) { - let chat = match &self.screen { - Screen::Chat(chat) => Some(chat.clone()), - _ => self.parked_chat.clone(), - }; - if let Some(chat) = chat { - chat.update(cx, |chat, cx| chat.set_update(info, cx)); + fn set_update(&mut self, snapshot: crate::update::Snapshot, cx: &mut Context) { + if let Some(chat) = &self.local_chat { + chat.update(cx, |chat, cx| chat.set_update(snapshot.clone(), cx)); } - cx.notify(); + if let Screen::Settings(settings) = &self.screen { + settings.update(cx, |settings, cx| settings.set_update(snapshot, cx)); + } + } + + fn show_about(&mut self, window: &mut Window, cx: &mut Context) { + match &self.screen { + Screen::Settings(settings) => { + settings.update(cx, |settings, cx| settings.show_about(cx)) + } + Screen::Chat(_) => self.show_settings(Section::About, cx), + _ => { + let details = format!( + "Version {}\n{}", + crate::ui::titlebar::TitleBar::version(), + self.updater.snapshot().label + ); + drop(window.prompt( + gpui::PromptLevel::Info, + crate::profile::DISPLAY_NAME, + Some(&details), + &["OK"], + cx, + )); + } + } + } + + fn update_action( + &mut self, + action: crate::update::UpdateAction, + window: &mut Window, + cx: &mut Context, + ) { + use crate::update::UpdateAction; + if action == UpdateAction::ManualDownload { + if let Some(url) = self.updater.snapshot().manual_download_url { + cx.open_url(&url); + } + return; + } + if action == UpdateAction::Restart { + if self.restart_prompt_pending + || self.updater.snapshot().state != crate::update::State::Ready + { + return; + } + self.restart_prompt_pending = true; + let answer = window.prompt(gpui::PromptLevel::Warning, "Restart Maple to update?", + Some("This stops running tasks and remote access on this computer. Editors using Maple must reconnect after the update."), + &["Later", "Restart to Update"], cx); + let controller = self.updater.clone(); + let backend = self.backend.clone(); + let api_url = backend.api_url().to_owned(); + let bridge = cx.spawn(async move |this, cx| { + let installed = if answer.await == Ok(1) { + backend + .spawn(async move { controller.install(api_url).await }) + .await + .ok() + .and_then(Result::ok) + .flatten() + } else { + None + }; + let _ = this.update(cx, |this, cx| { + this.restart_prompt_pending = false; + if let Some(path) = installed { + this.updater.shutdown(); + persist_window_state(); + if let Some(state) = cx.try_global::() { + state.quit_stop_attempted.set(true); + } + cx.set_restart_path(path); + cx.restart(); + } + }); + }); + ui::task::retain(&self.remote_bridges, bridge); + return; + } + let controller = self.updater.clone(); + if let UpdateAction::AutomaticDownloads(enabled) = action { + self.settings.automatic_update_downloads = enabled; + } + // Dropping a Tokio join handle deliberately detaches the operation. + drop(self.backend.spawn(async move { + match action { + UpdateAction::Check => controller.check().await, + UpdateAction::Download => controller.download().await, + UpdateAction::Later => controller.later().await, + UpdateAction::AutomaticDownloads(enabled) => { + controller.set_auto_download(enabled).await + } + UpdateAction::Restart | UpdateAction::ManualDownload => {} + } + })); } /// Tear down the chat screen and return to a fresh login form. A @@ -202,11 +293,7 @@ impl MapleApp { ); let backend = self.backend.clone(); let chat = cx.new(|cx| ChatScreen::new(backend, user_id.clone(), cx)); - // The release check may have finished while the login screen was - // up; the banner must not be lost with it. - if let Some(info) = crate::update::available() { - chat.update(cx, |chat, cx| chat.set_update(info.clone(), cx)); - } + chat.update(cx, |chat, cx| chat.set_update(self.updater.snapshot(), cx)); self.user_id = Some(user_id); self.parked_chat = None; self.screen = Screen::Chat(chat.clone()); @@ -638,6 +725,17 @@ impl MapleApp { cx, ) }); + screen.update(cx, |settings, cx| { + settings.set_update(self.updater.snapshot(), cx) + }); + cx.subscribe( + &screen, + |_, _, event: &crate::update::UpdateRequested, cx| { + let action = event.0; + cx.defer(move |cx| dispatch_update_action(action, cx)); + }, + ) + .detach(); cx.subscribe( &screen, |app, _, event: &ui::remote::ComputersChanged, cx| { @@ -716,6 +814,11 @@ impl MapleApp { /// Wire per-chat events: sign-out and the settings gear. fn subscribe_chat(&mut self, chat: &Entity, cx: &mut Context) { + cx.subscribe(chat, |_, _, event: &crate::update::UpdateRequested, cx| { + let action = event.0; + cx.defer(move |cx| dispatch_update_action(action, cx)); + }) + .detach(); cx.subscribe(chat, |app, _, _: &NavigationChanged, cx| { app.schedule_computers_sync(cx) }) @@ -848,6 +951,7 @@ impl Render for MapleApp { #[derive(Clone)] struct DesktopLifecycle { backend: Arc, + updater: crate::update::Controller, remote_enabled: Arc, can_background: bool, closing: std::rc::Rc>, @@ -855,16 +959,35 @@ struct DesktopLifecycle { } impl gpui::Global for DesktopLifecycle {} +/// Recheck at the final effect, including completions/prompts admitted before +/// installation began. Every explicit Quit path goes through this boundary. +fn may_finish_quit(cx: &App) -> bool { + !cx.try_global::() + .is_some_and(|state| state.updater.snapshot().state == crate::update::State::Installing) +} + +fn finish_quit(cx: &mut App) { + if may_finish_quit(cx) { + cx.quit(); + } else { + show_update_about(cx); + } +} + fn quit_app(cx: &mut App) { let Some(state) = cx.try_global::().cloned() else { - cx.quit(); + finish_quit(cx); return; }; + if state.updater.snapshot().state == crate::update::State::Installing { + show_update_about(cx); + return; + } if !state .remote_enabled .load(std::sync::atomic::Ordering::Acquire) { - cx.quit(); + finish_quit(cx); return; } if state.closing.replace(true) { @@ -876,8 +999,11 @@ fn quit_app(cx: &mut App) { .spawn(async move { crate::host::request_stop(&api).await }); cx.spawn(async move |cx| match stop.await { Ok(Ok(())) => cx.update(|cx| { - state.quit_stop_attempted.set(true); - cx.quit(); + if may_finish_quit(cx) { + state.quit_stop_attempted.set(true); + } + state.closing.set(false); + finish_quit(cx); }), result => { state.closing.set(false); @@ -916,8 +1042,10 @@ impl Render for QuitWarning { .child(ui::widgets::secondary_button("quit-maple-anyway").debug_selector(|| "quit-maple-anyway".to_string()).child("Quit anyway") .on_click(|_, _, cx| { // Do not repeat the failed stop in the app-quit callback. - if let Some(state) = cx.try_global::() { state.quit_stop_attempted.set(true); } - cx.quit(); + if may_finish_quit(cx) && let Some(state) = cx.try_global::() { + state.quit_stop_attempted.set(true); + } + finish_quit(cx); }))) } } @@ -926,9 +1054,19 @@ impl Render for QuitWarning { pub(crate) fn close_window(window: &mut Window, cx: &mut App) { persist_window_state(); let Some(state) = cx.try_global::().cloned() else { - cx.quit(); + finish_quit(cx); return; }; + if state.updater.snapshot().state == crate::update::State::Installing { + drop(window.prompt( + gpui::PromptLevel::Info, + "Maple is installing an update", + Some("Keep Maple open until the update finishes and Maple restarts."), + &["OK"], + cx, + )); + return; + } if !state .remote_enabled .load(std::sync::atomic::Ordering::Acquire) @@ -1111,7 +1249,7 @@ impl StartupError { let _ = this.update(cx, |this, cx| { this.stopping = false; match result { - Some(Ok(())) => cx.quit(), + Some(Ok(())) => finish_quit(cx), Some(Err(error)) => this.message = format!("Could not restart Maple: {error}"), None => {} } @@ -1160,7 +1298,7 @@ impl Render for StartupError { .rounded_md() .bg(gpui::rgb(ui::theme::bg_elevated())) .child("Close") - .on_click(|_, _, cx| cx.quit()), + .on_click(|_, _, cx| finish_quit(cx)), ) } } @@ -1183,7 +1321,7 @@ fn show_startup_error(error: String) { }, move |window, cx| { window.on_window_should_close(cx, |_, cx| { - cx.quit(); + finish_quit(cx); true }); cx.new(|_| StartupError::new(error)) @@ -1191,13 +1329,34 @@ fn show_startup_error(error: String) { ); if let Err(error) = result { log::error!("Could not show the startup error: {error}"); - cx.quit(); + finish_quit(cx); } else { cx.activate(true); } }); } +fn dispatch_update_action(action: crate::update::UpdateAction, cx: &mut App) { + for window in cx.windows() { + let _ = window.update(cx, |root, window, cx| { + if let Ok(app) = root.downcast::() { + app.update(cx, |app, cx| app.update_action(action, window, cx)); + } + }); + } +} + +fn show_update_about(cx: &mut App) { + for window in cx.windows() { + let _ = window.update(cx, |root, window, cx| { + if let Ok(app) = root.downcast::() { + window.activate_window(); + app.update(cx, |app, cx| app.show_about(window, cx)); + } + }); + } +} + pub fn run() { crate::init_logging(crate::LogOutput::FileAndStderr); log::debug!("startup: logging ready at {} ms", crate::startup_elapsed()); @@ -1244,6 +1403,11 @@ pub fn run() { } ui::spell::preload(); cx.on_action(|_: &QuitApp, cx| quit_app(cx)); + cx.on_action(|_: &crate::update::AboutAgent, cx| show_update_about(cx)); + cx.on_action(|_: &crate::update::CheckForUpdates, cx| { + show_update_about(cx); + dispatch_update_action(crate::update::UpdateAction::Check, cx); + }); #[cfg(target_os = "macos")] { cx.on_action(|_: &Hide, cx| cx.hide()); @@ -1315,8 +1479,13 @@ pub fn run() { WindowBounds::Windowed(bounds) }; let remote_enabled = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let updater = + crate::update::Controller::new(startup_settings.automatic_update_downloads); + let mut update_events = updater.subscribe(); + let quit_updater = updater.clone(); cx.set_global(DesktopLifecycle { backend: backend.clone(), + updater: updater.clone(), remote_enabled: remote_enabled.clone(), can_background: false, closing: Default::default(), @@ -1325,12 +1494,17 @@ pub fn run() { let quit_remote_enabled = remote_enabled.clone(); let quit_backend = backend.clone(); cx.on_app_quit(move |cx| { + quit_updater.shutdown(); let stop_attempted = cx .try_global::() .is_some_and(|state| state.quit_stop_attempted.get()); let backend = quit_backend.clone(); let remote_enabled = quit_remote_enabled.clone(); + let updater = quit_updater.clone(); async move { + let _ = backend + .spawn(async move { updater.cleanup_on_exit().await }) + .await; persist_window_state(); if stop_attempted { return; @@ -1357,6 +1531,7 @@ pub fn run() { let root_enabled = remote_enabled.clone(); let root_backend = backend.clone(); let root_settings = startup_settings.clone(); + let root_updater = updater.clone(); let window = cx .open_window(main_window_options(window_bounds), move |_, cx| { cx.new(move |cx| MapleApp { @@ -1375,6 +1550,8 @@ pub fn run() { tray, remote_bridges: std::cell::RefCell::new(Vec::new()), settings: root_settings, + updater: root_updater, + restart_prompt_pending: false, shortcuts: shortcut_runtime, titlebar: cx.new(|_| TitleBar::new(ui::titlebar::WINDOW_TITLE)), }) @@ -1419,22 +1596,22 @@ pub fn run() { } } } - // Ask for a newer release off the UI thread; the banner shows - // in the chat when one exists. - if crate::update::enabled() { - let check = backend.spawn(crate::update::check()); - let root_window = window; - cx.spawn(async move |cx| { - if let Ok(Some(info)) = check.await { - root_window - .update(cx, |app: &mut MapleApp, _window, cx| { - app.set_update(info, cx); - }) - .ok(); + // HTTP clients are created on the backend Tokio runtime. A watch + // stream pushes only state changes into the cached GPUI views. + drop(backend.spawn(updater.run())); + let root_window = window; + cx.spawn(async move |cx| { + while update_events.changed().await.is_ok() { + let snapshot = update_events.borrow_and_update().clone(); + if root_window + .update(cx, |app: &mut MapleApp, _, cx| app.set_update(snapshot, cx)) + .is_err() + { + break; } - }) - .detach(); - } + } + }) + .detach(); let root = window .update(cx, |app, window, cx| { // The only window: closing it from the window manager @@ -1496,6 +1673,11 @@ pub fn run() { crate::tray::Action::Quit => { cx.update(quit_app); } + crate::tray::Action::CheckForUpdates => { + cx.update(|cx| { + dispatch_update_action(crate::update::UpdateAction::Check, cx) + }); + } crate::tray::Action::Open => { cx.update(|cx| { if cx.windows().is_empty() { @@ -1595,6 +1777,46 @@ mod tests { }] } + #[gpui::test] + fn pending_quit_or_warning_cannot_finish_during_installation(cx: &mut gpui::TestAppContext) { + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); + let updater = crate::update::Controller::new(false); + updater.set_test_state(crate::update::State::Ready); + let pending = std::rc::Rc::new(std::cell::Cell::new(true)); + cx.update(|cx| { + cx.set_global(DesktopLifecycle { + backend, + updater: updater.clone(), + remote_enabled: Arc::new(std::sync::atomic::AtomicBool::new(true)), + can_background: false, + closing: pending.clone(), + quit_stop_attempted: Default::default(), + }); + assert!( + may_finish_quit(cx), + "quit/warning was admitted before installation" + ); + updater.set_test_state(crate::update::State::Installing); + assert!( + !may_finish_quit(cx), + "pending remote-stop completion must recheck" + ); + finish_quit(cx); + assert!(!cx.global::().quit_stop_attempted.get()); + assert!( + !may_finish_quit(cx), + "an earlier Quit anyway warning must recheck too" + ); + updater.set_test_state(crate::update::State::Error); + assert!( + may_finish_quit(cx), + "a failed installation restores ordinary quit" + ); + }); + } + fn project_shell(cx: &mut gpui::TestAppContext) -> Entity { cx.executor().allow_parking(); let backend = @@ -1652,6 +1874,8 @@ mod tests { tray: None, remote_bridges: Default::default(), settings, + updater: crate::update::Controller::new(true), + restart_prompt_pending: false, shortcuts, titlebar: cx.new(|_| TitleBar::new(ui::titlebar::WINDOW_TITLE)), } diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index 70aa37475..4e0dd9846 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -492,6 +492,17 @@ fn spawn_host() -> Result<(), String> { Ok(()) } +/// Restore host availability after an update failed before committing its swap. +/// This explicit recovery never replays work; ordinary reconnect loops still +/// cannot undo a deliberate host stop. Call only after the update fence drops. +#[cfg(feature = "desktop")] +pub async fn recover_after_failed_update(api_url: &str) -> Result<(), String> { + let endpoint = ipc::Endpoint::new(api_url)?; + let (connection, _) = connect(&endpoint, ConnectionMode::Client, true).await?; + drop(connection); + Ok(()) +} + pub fn run(api_url: String) -> Result<(), String> { let endpoint = ipc::Endpoint::new(&api_url)?; let ownership = endpoint.lock("host.lock")?; @@ -834,6 +845,170 @@ pub fn stop(api_url: &str) -> Result<(), String> { }) } +/// Run an already verified replacement while this profile cannot start a host. +/// +/// Call from a blocking task, never the UI or an async executor thread. The caller +/// also owns the application-path installation lock and revalidates staging in +/// `replace`. File guards live until replacement finishes, including when the +/// async caller drops its blocking-task handle. Normal host startup already owns +/// `startup.lock` while waiting for the child's handshake; the child must not try +/// to acquire that same lock a second time. +#[cfg(feature = "desktop")] +pub fn with_update_fence( + api_url: &str, + replace: impl FnOnce() -> Result, +) -> Result { + let endpoint = ipc::Endpoint::new(api_url)?; + with_update_fence_endpoint( + &endpoint, + Duration::from_secs(45), + || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| "Could not prepare update shutdown".to_string())?; + runtime.block_on(request_stop_endpoint(&endpoint)) + }, + replace, + ) +} + +#[cfg(feature = "desktop")] +fn with_update_fence_endpoint( + endpoint: &ipc::Endpoint, + timeout: Duration, + stop: impl FnOnce() -> Result<(), String>, + replace: impl FnOnce() -> Result, +) -> Result { + let startup = endpoint.lock("startup.lock")?; + wait_update_lock(&startup, timeout, "Another Maple host is starting")?; + stop()?; + // Stop acknowledgement and even socket EOF can precede the host's final + // drop of ownership. Acquiring ownership proves the drain has completed + // and prevents direct `host serve` attempts during the file replacement. + let ownership = endpoint.lock("host.lock")?; + wait_update_lock(&ownership, timeout, "Maple host is still shutting down")?; + let result = replace(); + // The new process must acquire these locks normally after the transaction. + // Keep this order explicit: never release admission before replacement. + drop(ownership); + drop(startup); + result +} + +#[cfg(feature = "desktop")] +fn wait_update_lock(file: &std::fs::File, timeout: Duration, busy: &str) -> Result<(), String> { + let deadline = std::time::Instant::now() + timeout; + loop { + match file.try_lock() { + Ok(()) => return Ok(()), + Err(std::fs::TryLockError::WouldBlock) if std::time::Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(std::fs::TryLockError::WouldBlock) => return Err(busy.to_string()), + Err(_) => return Err("Could not acquire Maple update ownership".into()), + } + } +} + +#[cfg(all(test, feature = "desktop"))] +mod update_install_tests { + use super::*; + use std::sync::atomic::AtomicBool; + + #[test] + fn update_waits_for_drain_and_holds_startup_and_ownership_during_replacement() { + let directory = tempfile::tempdir().unwrap(); + let endpoint = ipc::Endpoint::for_test(directory.path().to_path_buf()); + let old_host = endpoint.lock("host.lock").unwrap(); + old_host.lock().unwrap(); + let queued_start = endpoint.lock("startup.lock").unwrap(); + let direct_start = endpoint.lock("host.lock").unwrap(); + let result = with_update_fence_endpoint( + &endpoint, + Duration::from_secs(2), + || { + assert!(matches!( + queued_start.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + drop(old_host); + Ok(()) + }, + || { + assert!(matches!( + queued_start.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + assert!(matches!( + direct_start.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + Ok(42) + }, + ) + .unwrap(); + assert_eq!(result, 42); + queued_start.try_lock().unwrap(); + direct_start.try_lock().unwrap(); + } + + #[test] + fn shutdown_failure_never_replaces_and_releases_startup_admission() { + let directory = tempfile::tempdir().unwrap(); + let endpoint = ipc::Endpoint::for_test(directory.path().to_path_buf()); + let replaced = AtomicBool::new(false); + let result = with_update_fence_endpoint( + &endpoint, + Duration::from_millis(30), + || Err("stop failed".into()), + || { + replaced.store(true, Ordering::SeqCst); + Ok(()) + }, + ); + assert_eq!(result.unwrap_err(), "stop failed"); + assert!(!replaced.load(Ordering::SeqCst)); + endpoint.lock("startup.lock").unwrap().try_lock().unwrap(); + } + + #[test] + fn stop_acknowledgement_does_not_bypass_a_host_that_keeps_ownership() { + let directory = tempfile::tempdir().unwrap(); + let endpoint = ipc::Endpoint::for_test(directory.path().to_path_buf()); + let old_host = endpoint.lock("host.lock").unwrap(); + old_host.lock().unwrap(); + let replaced = AtomicBool::new(false); + let result = with_update_fence_endpoint( + &endpoint, + Duration::from_millis(30), + || Ok(()), + || { + replaced.store(true, Ordering::SeqCst); + Ok(()) + }, + ); + assert!(result.unwrap_err().contains("shutting down")); + assert!(!replaced.load(Ordering::SeqCst)); + endpoint.lock("startup.lock").unwrap().try_lock().unwrap(); + } + + #[test] + fn replacement_failure_releases_both_locks_for_recovery() { + let directory = tempfile::tempdir().unwrap(); + let endpoint = ipc::Endpoint::for_test(directory.path().to_path_buf()); + let result: Result<(), String> = with_update_fence_endpoint( + &endpoint, + Duration::from_millis(30), + || Ok(()), + || Err("replacement failed".into()), + ); + assert_eq!(result.unwrap_err(), "replacement failed"); + endpoint.lock("startup.lock").unwrap().try_lock().unwrap(); + endpoint.lock("host.lock").unwrap().try_lock().unwrap(); + } +} + #[cfg(feature = "acp")] pub fn acp(api_url: &str) -> Result<(), String> { let runtime = tokio::runtime::Runtime::new().map_err(|e| e.to_string())?; diff --git a/apps/maple-agent/app/src/profile.rs b/apps/maple-agent/app/src/profile.rs index 340524c9a..6bc3e1212 100644 --- a/apps/maple-agent/app/src/profile.rs +++ b/apps/maple-agent/app/src/profile.rs @@ -32,6 +32,11 @@ pub fn build_info() -> serde_json::Value { debug_assert_eq!(info["auth_origin"], serde_json::json!(AUTH_ORIGIN)); debug_assert_eq!(info["pcr_environment"], PCR_ENVIRONMENT); debug_assert_eq!(info["update_tag_prefix"], UPDATE_TAG_PREFIX); + debug_assert_eq!(info["update_feed_url"], serde_json::json!(UPDATE_FEED_URL)); + debug_assert_eq!( + info["update_public_keys"], + serde_json::json!(UPDATE_PUBLIC_KEYS) + ); debug_assert_eq!(info["prerelease"], PRERELEASE); info } @@ -91,6 +96,11 @@ mod tests { assert_eq!(info["auth_origin"], serde_json::json!(AUTH_ORIGIN)); assert_eq!(info["client_id"], CLIENT_ID); assert_eq!(info["update_tag_prefix"], UPDATE_TAG_PREFIX); + assert_eq!(info["update_feed_url"], serde_json::json!(UPDATE_FEED_URL)); + assert_eq!( + info["update_public_keys"], + serde_json::json!(UPDATE_PUBLIC_KEYS) + ); assert_eq!(info["prerelease"], PRERELEASE); } diff --git a/apps/maple-agent/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 1710c260c..dbb2216e7 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -54,6 +54,10 @@ pub struct AppSettings { /// while the window is not focused. #[serde(default = "default_desktop_notifications")] pub desktop_notifications: bool, + /// Download verified updates in the background. Installation always + /// requires the user's explicit Restart to Update action. + #[serde(default = "default_automatic_update_downloads")] + pub automatic_update_downloads: bool, /// Skip looping and reveal animations. gpui reads no OS preference for /// this, so it is a Maple setting. #[serde(default)] @@ -196,6 +200,10 @@ fn default_desktop_notifications() -> bool { true } +fn default_automatic_update_downloads() -> bool { + true +} + fn default_tool_summaries() -> bool { true } @@ -214,6 +222,7 @@ impl Default for AppSettings { project_names: std::collections::HashMap::new(), remote_sidebars: std::collections::HashMap::new(), desktop_notifications: default_desktop_notifications(), + automatic_update_downloads: default_automatic_update_downloads(), reduce_motion: false, harness_instructions: String::new(), window: None, @@ -477,6 +486,17 @@ mod tests { assert!(!settings.application_vim_enabled); } + #[test] + fn existing_settings_files_default_automatic_update_downloads_to_on() { + let settings: AppSettings = + serde_json::from_str(r#"{"theme":"dark","desktop_notifications":false}"#) + .expect("deserialize settings from before automatic downloads"); + assert!(settings.automatic_update_downloads); + assert_eq!(settings.theme, "dark"); + assert!(!settings.desktop_notifications); + assert!(AppSettings::default().automatic_update_downloads); + } + #[test] fn existing_settings_files_default_chat_reading_to_system_14() { let mut json = serde_json::to_value(AppSettings::default()).expect("serialize"); @@ -506,6 +526,7 @@ mod tests { // A second queued change must not lose the first: both go through // the writer thread in order. update_settings_in_background(|settings| settings.tts_speed = 1.5); + update_settings_in_background(|settings| settings.automatic_update_downloads = false); update_settings_and_wait(|_| {}); let reloaded = load_settings(); @@ -517,6 +538,7 @@ mod tests { assert_eq!(reloaded.tts_voice, "neutral_male"); assert!((reloaded.tts_speed - 1.5).abs() < 0.01); + assert!(!reloaded.automatic_update_downloads); } /// Without an explicit XDG_CONFIG_HOME, a test build must resolve the diff --git a/apps/maple-agent/app/src/tray.rs b/apps/maple-agent/app/src/tray.rs index 7edfc18d5..956f6d298 100644 --- a/apps/maple-agent/app/src/tray.rs +++ b/apps/maple-agent/app/src/tray.rs @@ -7,14 +7,16 @@ use tray_icon::{ pub enum Action { Open, Quit, + CheckForUpdates, } pub struct StatusIcon(TrayIcon); impl StatusIcon { pub fn new() -> Result<(Self, tokio::sync::mpsc::Receiver), String> { let menu = Menu::new(); let open = MenuItem::with_id("maple-open", "Open Maple", true, None); + let check = MenuItem::with_id("maple-updates", "Check for Updates…", true, None); let quit = MenuItem::with_id("maple-quit", "Quit Maple", true, None); - menu.append_items(&[&open, &quit]) + menu.append_items(&[&open, &check, &quit]) .map_err(|_| "Could not create the Maple tray menu")?; let image = image::load_from_memory(include_bytes!("../packaging/maple-agent.png")) .map_err(|_| "Could not load the Maple tray icon")? @@ -35,6 +37,7 @@ impl StatusIcon { let action = match event.id.as_ref() { "maple-open" => Action::Open, "maple-quit" => Action::Quit, + "maple-updates" => Action::CheckForUpdates, _ => return, }; let _ = sender.try_send(action); diff --git a/apps/maple-agent/app/src/ui/chat/mod.rs b/apps/maple-agent/app/src/ui/chat/mod.rs index 24e4a395f..2bbbd4cec 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -638,7 +638,7 @@ pub struct ChatScreen { /// First line of each queued message, for the chips. queue_previews: Vec, /// A newer release, until the banner is dismissed. - update: Option, + update: crate::update::Snapshot, queue_busy: bool, queue_edit: Option, /// Slash commands from the installed skills of the current project root. @@ -1216,7 +1216,7 @@ impl ChatScreen { queue_previews: Vec::new(), queue_busy: false, queue_edit: None, - update: None, + update: crate::update::Snapshot::initial(), slash_commands: Vec::new(), slash_selected: None, question_focus_pending: false, @@ -3345,15 +3345,19 @@ impl ChatScreen { cx.notify(); } - pub fn set_update(&mut self, info: crate::update::UpdateInfo, cx: &mut Context) { - self.update = Some(info); - cx.notify(); + pub fn set_update(&mut self, snapshot: crate::update::Snapshot, cx: &mut Context) { + if self.update != snapshot { + self.update = snapshot; + cx.notify(); + } } - /// Banner for a newer release, with a link to its page. fn render_update_banner(&self, cx: &mut Context) -> Option
{ - let info = self.update.as_ref()?; - let url = info.url.clone(); + let snapshot = &self.update; + if !snapshot.show_banner { + return None; + } + let action = snapshot.primary; Some( div() .flex() @@ -3368,42 +3372,35 @@ impl ChatScreen { .text_sm() .text_color(gpui::rgb(theme::text_primary())) .child(icon("arrow-up", px(14.), theme::accent())) + .child(div().flex_1().child(snapshot.label.clone())) + .when_some(action, |row, action| { + row.child( + div() + .id("update-primary") + .px_2() + .py_0p5() + .rounded(theme::RADIUS_SM) + .text_color(gpui::rgb(theme::accent())) + .hover(|style| style.bg(theme::overlay_hover()).cursor_pointer()) + .on_click(cx.listener(move |_, _, _, cx| { + cx.emit(crate::update::UpdateRequested(action)); + })) + .child(snapshot.primary_label), + ) + }) .child( div() - .flex_1() - .child(format!("Maple v{} is available", info.version)), - ) - .child( - div() - .id("update-open") + .id("update-later") .px_2() .py_0p5() .rounded(theme::RADIUS_SM) - .text_color(gpui::rgb(theme::accent())) - .hover(|style| style.bg(theme::overlay_hover()).cursor_pointer()) - .on_click(cx.listener(move |this, _event, _window, cx| { - if let Err(error) = webbrowser::open(&url) { - this.notice = - Some(format!("Could not open browser: {error}").into()); - } - cx.notify(); - })) - .child("Download"), - ) - .child( - div() - .id("update-dismiss") - .size_5() - .flex() - .items_center() - .justify_center() - .rounded(theme::RADIUS_SM) .hover(|style| style.bg(theme::overlay_hover()).cursor_pointer()) - .on_click(cx.listener(|this, _event, _window, cx| { - this.update = None; - cx.notify(); + .on_click(cx.listener(|_, _, _, cx| { + cx.emit(crate::update::UpdateRequested( + crate::update::UpdateAction::Later, + )); })) - .child(icon("x", px(12.), theme::text_secondary())), + .child("Later"), ), ) } @@ -5680,6 +5677,7 @@ impl ChatScreen { } } +impl EventEmitter for ChatScreen {} impl EventEmitter for ChatScreen {} impl EventEmitter for ChatScreen {} diff --git a/apps/maple-agent/app/src/ui/menus.rs b/apps/maple-agent/app/src/ui/menus.rs index 8a1d66647..294cc943d 100644 --- a/apps/maple-agent/app/src/ui/menus.rs +++ b/apps/maple-agent/app/src/ui/menus.rs @@ -62,6 +62,12 @@ fn app_menus(macos: bool) -> Vec { fn maple_menu_items(macos: bool) -> Vec { let mut items = vec![ + MenuItem::action( + format!("About {}", crate::profile::DISPLAY_NAME), + crate::update::AboutAgent, + ), + MenuItem::action("Check for Updates…", crate::update::CheckForUpdates), + MenuItem::separator(), MenuItem::action("Settings…", chat::OpenAppSettings), MenuItem::separator(), MenuItem::os_submenu("Services", SystemMenuType::Services), @@ -104,6 +110,8 @@ mod tests { assert_eq!( maple_action_names(true), vec![ + format!("About {}", crate::profile::DISPLAY_NAME), + "Check for Updates…".to_string(), "Settings…".to_string(), format!("Hide {}", crate::profile::DISPLAY_NAME), "Hide Others".to_string(), @@ -118,6 +126,8 @@ mod tests { assert_eq!( maple_action_names(false), vec![ + format!("About {}", crate::profile::DISPLAY_NAME), + "Check for Updates…".to_string(), "Settings…".to_string(), format!("Quit {}", crate::profile::DISPLAY_NAME) ] diff --git a/apps/maple-agent/app/src/ui/settings.rs b/apps/maple-agent/app/src/ui/settings.rs index b97e55959..f91aa3fbe 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -129,6 +129,7 @@ pub struct SettingsScreen { remote_screen: Entity, user_id: String, settings: AppSettings, + update: crate::update::Snapshot, /// `settings.theme` parsed once; render only reads the label. theme: theme::Preference, section: Section, @@ -236,11 +237,23 @@ impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} +impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl EventEmitter for SettingsScreen {} impl SettingsScreen { + pub fn show_about(&mut self, cx: &mut Context) { + self.select_section(Section::About, cx); + } + + pub fn set_update(&mut self, snapshot: crate::update::Snapshot, cx: &mut Context) { + if self.update != snapshot { + self.update = snapshot; + cx.notify(); + } + } + pub fn update_remote_status( &mut self, status: crate::remote::manager::Status, @@ -343,6 +356,7 @@ impl SettingsScreen { user_id, theme: theme::Preference::parse(&settings.theme), settings, + update: crate::update::Snapshot::initial(), section, popup: Popup::new(|this| &mut this.popup, cx), account: AccountState::new(application_vim_enabled, application_focus.clone(), cx), @@ -1746,14 +1760,37 @@ impl SettingsScreen { "Version", format!("v{}", crate::ui::titlebar::TitleBar::version()), )) - .child(info_row( - "Update", - match crate::update::available() { - Some(info) => format!("v{} is available at {}", info.version, info.url), - None if crate::update::enabled() => "Up to date".to_string(), - None => "Update check disabled".to_string(), - }, + .child(info_row("Update", self.update.label.clone())) + .child(toggle_row( + "Automatically download updates", + "Download verified updates in the background. Installation always waits for Restart to Update.", + self.settings.automatic_update_downloads, + cx.listener(|this, _, _, cx| { + let enabled = !this.settings.automatic_update_downloads; + this.edit_setting(move |settings| settings.automatic_update_downloads = enabled, cx); + cx.emit(crate::update::UpdateRequested(crate::update::UpdateAction::AutomaticDownloads(enabled))); + }), )) + .child(div().flex().gap_3() + .child(widgets::ghost_button("updates-check") + .on_click(cx.listener(|_, _, _, cx| cx.emit(crate::update::UpdateRequested(crate::update::UpdateAction::Check)))) + .child(if self.update.busy() { "Checking / updating…" } else if self.update.state == crate::update::State::Error { "Retry" } else { "Check for Updates" })) + .when_some(self.update.primary.filter(|action| *action != crate::update::UpdateAction::Check), |row, action| row.child( + widgets::ghost_button("updates-primary") + .on_click(cx.listener(move |_, _, _, cx| cx.emit(crate::update::UpdateRequested(action)))) + .child(self.update.primary_label) + )) + .when(self.update.state == crate::update::State::Error && self.update.manual_download_url.is_some(), |row| row.child( + widgets::ghost_button("updates-manual") + .on_click(cx.listener(|_, _, _, cx| cx.emit(crate::update::UpdateRequested(crate::update::UpdateAction::ManualDownload)))) + .child("Download manually") + )) + .when(self.update.state == crate::update::State::Ready, |row| row.child( + widgets::ghost_button("updates-later") + .on_click(cx.listener(|_, _, _, cx| cx.emit(crate::update::UpdateRequested(crate::update::UpdateAction::Later)))) + .child("Later") + )) + ) .child(info_row("Backend", self.backend.api_url().to_string())) .child(info_row( "Config directory", diff --git a/apps/maple-agent/app/src/update.rs b/apps/maple-agent/app/src/update.rs index 1c9f26f3c..d389abb68 100644 --- a/apps/maple-agent/app/src/update.rs +++ b/apps/maple-agent/app/src/update.rs @@ -1,450 +1,763 @@ -//! Discover Agent releases for the compiled channel independently of Research. -//! -//! The desktop app checks once per launch and shows a release-page banner. -//! Nothing is downloaded or installed. Prod uses `maple-agent-v` stable -//! releases; Dev uses `maple-agent-dev-v` GitHub prereleases. Packaged builds -//! pin the repository. Local builds retain `MAPLE_UPDATE_REPO` overrides. -//! `MAPLE_DISABLE_UPDATE_CHECK=1` turns the check off. +//! Agent-only update orchestration. All requests and filesystem operations run +//! on the backend runtime; views receive precomputed, truthful snapshots. -use std::future::Future; -use std::sync::OnceLock; -use std::time::Duration; - -use semver::Version; - -const DEFAULT_REPO: &str = "MaplePrivacyLabs/Maple"; -#[derive(Clone, Copy)] -struct ReleaseChannel { - tag_prefix: &'static str, - prerelease: bool, -} - -const CHANNEL: ReleaseChannel = ReleaseChannel { - tag_prefix: crate::profile::UPDATE_TAG_PREFIX, - prerelease: crate::profile::PRERELEASE, +use std::path::{Path, PathBuf}; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; +use std::time::Duration; -#[cfg(test)] -const PROD_CHANNEL: ReleaseChannel = ReleaseChannel { - tag_prefix: "maple-agent-v", - prerelease: false, +use maple_agent_updater::{ + CheckOutcome, Config, PreparedUpdate, Revalidation, Target, Updater, VerifiedRelease, }; -#[cfg(test)] -const DEV_CHANNEL: ReleaseChannel = ReleaseChannel { - tag_prefix: "maple-agent-dev-v", - prerelease: true, -}; -const RELEASES_PER_PAGE: usize = 100; -const MAX_RELEASE_PAGES: usize = 10; -const MAX_PAGE_BYTES: usize = 16 * 1024 * 1024; +use tokio::sync::{Mutex, OnceCell, watch}; +use tokio_util::sync::CancellationToken; -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct UpdateInfo { - /// Agent version without its release-tag prefix. - pub version: String, - /// Canonical GitHub release page for the user to open. - pub url: String, -} +gpui::actions!(maple_updates, [AboutAgent, CheckForUpdates]); -static AVAILABLE: OnceLock = OnceLock::new(); +pub struct UpdateRequested(pub UpdateAction); -/// The newer release found by [`check`], if any. -pub fn available() -> Option<&'static UpdateInfo> { - AVAILABLE.get() +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Disabled, + Idle, + Checking, + Current, + Available, + Downloading, + Ready, + Installing, + Error, } -pub fn enabled() -> bool { - !crate::env::env_flag("MAPLE_DISABLE_UPDATE_CHECK") +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UpdateAction { + Check, + Download, + ManualDownload, + Restart, + Later, + AutomaticDownloads(bool), } -/// Admit only GitHub owner/repository names, never an arbitrary URL or path. -fn valid_repo(repo: &str) -> bool { - let Some((owner, name)) = repo.split_once('/') else { - return false; - }; - !owner.is_empty() - && owner.len() <= 39 - && owner.starts_with(|ch: char| ch.is_ascii_alphanumeric()) - && owner.ends_with(|ch: char| ch.is_ascii_alphanumeric()) - && owner - .bytes() - .all(|ch| ch.is_ascii_alphanumeric() || ch == b'-') - && !name.is_empty() - && name.len() <= 100 - && name != "." - && name != ".." - && name - .bytes() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'-' | b'_' | b'.')) +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Snapshot { + pub state: State, + pub label: String, + pub version: Option, + pub primary: Option, + pub primary_label: &'static str, + pub manual_download_url: Option, + pub show_banner: bool, } -fn repo() -> Option { - configured_repo( - crate::profile::PACKAGED, - crate::env::env_string("MAPLE_UPDATE_REPO"), - ) +impl Snapshot { + fn new(state: State, label: impl Into) -> Self { + Self { + state, + label: label.into(), + version: None, + primary: None, + primary_label: "", + manual_download_url: None, + show_banner: false, + } + } + pub fn initial() -> Self { + match disabled_reason( + crate::profile::PROFILE, + crate::profile::UPDATE_PUBLIC_KEYS, + crate::env::env_flag("MAPLE_DISABLE_UPDATE_CHECK"), + ) { + Some(reason) => Self::new(State::Disabled, reason), + None => Self::new(State::Idle, "Not checked yet"), + } + } + pub fn busy(&self) -> bool { + matches!( + self.state, + State::Checking | State::Downloading | State::Installing + ) + } } -fn configured_repo(packaged: bool, configured: Option) -> Option { - let repo = if packaged { - DEFAULT_REPO.to_string() +fn disabled_reason(profile: &str, keys: &[&str], hard_off: bool) -> Option<&'static str> { + if hard_off { + Some("Update checks disabled") + } else if profile != "prod" { + Some("Updates are available in packaged Prod builds") + } else if keys.is_empty() { + Some("Updates not configured") } else { - configured.unwrap_or_else(|| DEFAULT_REPO.to_string()) - }; - if valid_repo(&repo) { - Some(repo) - } else { - log::debug!("update check: invalid repository override"); None } } -/// Ask GitHub for stable Agent releases. Runs on the backend runtime; the -/// HTTP client and the overall deadline must be created inside Tokio. -pub async fn check() -> Option { - if !enabled() { - return None; - } - let repo = repo()?; - let current = Version::parse(env!("CARGO_PKG_VERSION")).ok()?; - let client = reqwest::Client::builder() - .user_agent(concat!("maple-agent/", env!("CARGO_PKG_VERSION"))) - // A moved repository must be explicitly configured, not silently - // followed to a different release source. - .redirect(reqwest::redirect::Policy::none()) - .timeout(Duration::from_secs(15)) - .build() - .ok()?; - let version = tokio::time::timeout( - Duration::from_secs(30), - find_newer_release(CHANNEL, ¤t, |page| fetch_page(&client, &repo, page)), - ) - .await - .ok()??; - let info = release_info(CHANNEL, &repo, version)?; - let _ = AVAILABLE.set(info.clone()); - Some(info) +struct Service { + updater: Arc, + installed_path: PathBuf, + manual_only: bool, +} +#[derive(Default)] +struct ReleaseState { + release: Option, + prepared: Option, + installation: Option, + install_lock: Option, + deferred: Option, } +struct Inner { + service: OnceCell, + operation: Mutex<()>, + releases: Mutex, + snapshots: watch::Sender, + automatic: AtomicBool, + cancel: CancellationToken, +} + +/// One controller per desktop process. The operation mutex covers each complete +/// check/download/install; repeated clicks cannot launch competing transactions. +#[derive(Clone)] +pub struct Controller(Arc); -async fn fetch_page( - client: &reqwest::Client, - repo: &str, - page: usize, -) -> Option> { - let url = format!( - "https://api.github.com/repos/{repo}/releases?per_page={RELEASES_PER_PAGE}&page={page}" - ); - let mut response = client - .get(url) - .header("Accept", "application/vnd.github+json") - .header("X-GitHub-Api-Version", "2022-11-28") - .send() +impl Controller { + pub fn new(automatic_downloads: bool) -> Self { + let (snapshots, _) = watch::channel(Snapshot::initial()); + Self(Arc::new(Inner { + service: OnceCell::new(), + operation: Mutex::new(()), + releases: Mutex::new(ReleaseState::default()), + snapshots, + automatic: AtomicBool::new(automatic_downloads), + cancel: CancellationToken::new(), + })) + } + pub fn snapshot(&self) -> Snapshot { + self.0.snapshots.borrow().clone() + } + #[cfg(test)] + pub(crate) fn set_test_state(&self, state: State) { + self.publish(Snapshot::new(state, "Test update state")); + } + pub fn subscribe(&self) -> watch::Receiver { + self.0.snapshots.subscribe() + } + pub fn shutdown(&self) { + self.0.cancel.cancel(); + } + /// Best effort on graceful exit. Keep the authenticated downloaded payload + /// for restoration, but remove the derived same-volume staging bundle. + pub async fn cleanup_on_exit(&self) { + let Ok(_operation) = + tokio::time::timeout(Duration::from_secs(2), self.0.operation.lock()).await + else { + return; + }; + let mut releases = self.0.releases.lock().await; + let staging = releases.installation.take(); + let lock = releases.install_lock.take(); + drop(releases); + if let Some(staging) = staging { + discard_preparation_with_lock(staging, lock).await; + } + } + fn publish(&self, snapshot: Snapshot) { + self.0.snapshots.send_if_modified(|current| { + if *current == snapshot { + false + } else { + *current = snapshot; + true + } + }); + } + fn begin_check(&self) { + let previous = self.snapshot(); + let mut checking = Snapshot::new(State::Checking, "Checking for updates…"); + checking.version = previous.version; + checking.manual_download_url = previous.manual_download_url; + self.publish(checking); + } + fn fail(&self, message: impl Into) { + let previous = self.snapshot(); + let mut snapshot = Snapshot::new(State::Error, message); + snapshot.version = previous.version; + snapshot.manual_download_url = previous.manual_download_url; + snapshot.show_banner = snapshot.version.is_some(); + snapshot.primary = Some(UpdateAction::Check); + snapshot.primary_label = "Retry"; + self.publish(snapshot); + } + async fn service(&self) -> Result<&Service, String> { + self.0 + .service + .get_or_try_init(|| async { + if self.snapshot().state == State::Disabled { + return Err(self.snapshot().label); + } + tokio::task::spawn_blocking(build_service) + .await + .map_err(|_| "Could not initialize updates".to_string())? + }) + .await + } + /// Owned by the desktop lifetime. Cancellation interrupts polling and HTTP + /// work; a blocking atomic replacement, once started, finishes deliberately. + pub async fn run(self) { + if self.snapshot().state == State::Disabled || self.0.cancel.is_cancelled() { + return; + } + let startup = async { + if let Ok(service) = self.service().await { + let installed = service.installed_path.clone(); + let _ = tokio::task::spawn_blocking(move || { + maple_agent_updater::installer::cleanup_current_installation( + &installed, + env!("CARGO_PKG_VERSION"), + ) + }) + .await; + match service.updater.restore_prepared().await { + Ok(Some(prepared)) => { + let mut releases = self.0.releases.lock().await; + releases.release = Some(prepared.release.clone()); + releases.prepared = Some(prepared); + } + Ok(None) => {} + Err(_) => self.fail("Saved update could not be verified; checking again"), + } + } + self.check().await; + }; + tokio::select! { _ = self.0.cancel.cancelled() => return, _ = startup => {} } + loop { + let delay = Duration::from_secs(3300 + rand::random::() % 601); + tokio::select! { + _ = self.0.cancel.cancelled() => return, + _ = tokio::time::sleep(delay) => {}, + } + tokio::select! { _ = self.0.cancel.cancelled() => return, _ = self.check() => {} } + } + } + pub async fn check(&self) { + if self.0.cancel.is_cancelled() { + return; + } + let Ok(_operation) = self.0.operation.try_lock() else { + return; + }; + self.check_inner().await; + } + async fn check_inner(&self) { + if self.snapshot().state == State::Disabled { + return; + } + self.begin_check(); + let service = match self.service().await { + Ok(service) => service, + Err(error) => { + self.fail(error); + return; + } + }; + match service.updater.check().await { + Ok(CheckOutcome::Current | CheckOutcome::Withdrawn) => { + let mut releases = self.0.releases.lock().await; + releases.release = None; + releases.prepared = None; + let staging = releases.installation.take(); + let lock = releases.install_lock.take(); + drop(releases); + if let Some(staging) = staging { + discard_preparation_with_lock(staging, lock).await; + } + let _ = service.updater.clear_prepared().await; + let mut snapshot = Snapshot::new(State::Current, "Up to date"); + snapshot.primary = Some(UpdateAction::Check); + snapshot.primary_label = "Check for Updates"; + self.publish(snapshot); + } + Ok(CheckOutcome::Available(release)) => self.accept_release(service, *release).await, + Err(_) => { + self.fail("Could not check for updates. Your current app is still available.") + } + } + } + async fn accept_release(&self, service: &Service, release: VerifiedRelease) { + let (prepared, previous_staging, previous_lock) = { + let mut releases = self.0.releases.lock().await; + let prepared = releases + .prepared + .as_ref() + .filter(|p| p.release.envelope == release.envelope) + .cloned(); + let (previous_staging, previous_lock) = if prepared.is_none() { + releases.prepared = None; + (releases.installation.take(), releases.install_lock.take()) + } else { + (None, None) + }; + releases.release = Some(release.clone()); + (prepared, previous_staging, previous_lock) + }; + if let Some(staging) = previous_staging { + discard_preparation_with_lock(staging, previous_lock).await; + } + if let Some(prepared) = prepared.filter(|_| !service.manual_only) { + match self.stage_prepared(prepared).await { + Ok(()) => self.release_snapshot(service, &release, State::Ready).await, + Err(error) => self.fail(error), + } + } else if self.0.automatic.load(Ordering::Relaxed) && !service.manual_only { + self.download_inner(service, release).await; + } else { + self.release_snapshot(service, &release, State::Available) + .await; + } + } + async fn stage_prepared(&self, prepared: PreparedUpdate) -> Result<(), String> { + // A restored Ready flag is never trusted. Extract/check the signed + // package before offering Restart, then check it again at installation. + let mut releases = self.0.releases.lock().await; + let previous = releases.installation.take(); + let previous_lock = releases.install_lock.take(); + drop(releases); + let (installation, lock) = tokio::task::spawn_blocking(move || { + let lock = match previous_lock { + Some(lock) => lock, + None => { + maple_agent_updater::installer::acquire_install_lock(&prepared.installed_path) + .map_err(|e| e.to_string())? + } + }; + if let Some(previous) = previous { + match maple_agent_updater::installer::revalidate(&previous, &prepared) { + Ok(()) => return Ok((previous, lock)), + Err(error) => { + let _ = maple_agent_updater::installer::discard_preparation(&previous); + return Err(error.to_string()); + } + } + } + let installation = + maple_agent_updater::installer::prepare(&prepared).map_err(|e| e.to_string())?; + Ok((installation, lock)) + }) .await - .map_err(|_| log::debug!("update check: release request failed")) - .ok()?; - if !response.status().is_success() { - log::debug!( - "update check: release request returned {}", - response.status() + .map_err(|_| "Could not prepare the update".to_string())??; + let mut releases = self.0.releases.lock().await; + releases.installation = Some(installation); + releases.install_lock = Some(lock); + Ok(()) + } + async fn release_snapshot(&self, service: &Service, release: &VerifiedRelease, state: State) { + let version = release.manifest.version.clone(); + let mut snapshot = Snapshot::new( + state, + match state { + State::Ready => format!("v{version} is ready to install"), + State::Downloading => format!("Downloading v{version}…"), + _ if service.manual_only => { + format!("v{version} is available for manual installation") + } + _ => format!("v{version} is available"), + }, ); - return None; + snapshot.version = Some(version.clone()); + snapshot.manual_download_url = manual_download_url(release); + if state == State::Ready { + snapshot.primary = Some(UpdateAction::Restart); + snapshot.primary_label = "Restart to Update"; + } else if state == State::Available { + snapshot.primary = Some(if service.manual_only { + UpdateAction::ManualDownload + } else { + UpdateAction::Download + }); + snapshot.primary_label = "Download"; + } + snapshot.show_banner = state != State::Downloading + && self.0.releases.lock().await.deferred.as_ref() != Some(&version); + self.publish(snapshot); } - let mut bytes = Vec::new(); - while let Some(chunk) = response.chunk().await.ok()? { - if chunk.len() > MAX_PAGE_BYTES.saturating_sub(bytes.len()) { - log::debug!("update check: release page exceeds size limit"); - return None; + pub async fn download(&self) { + if self.0.cancel.is_cancelled() { + return; + } + let Ok(_operation) = self.0.operation.try_lock() else { + return; + }; + let service = match self.service().await { + Ok(service) => service, + Err(error) => { + self.fail(error); + return; + } + }; + let release = self.0.releases.lock().await.release.clone(); + if let Some(release) = release { + self.download_inner(service, release).await; } - bytes.extend_from_slice(&chunk); } - serde_json::from_slice(&bytes).ok() -} - -/// GitHub's list order is not version order. Finish the bounded scan before -/// selecting a version; partial results must never produce an update banner. -async fn find_newer_release( - channel: ReleaseChannel, - current: &Version, - mut fetch: F, -) -> Option -where - F: FnMut(usize) -> Fut, - Fut: Future>>, -{ - let mut newest = None; - for page in 1..=MAX_RELEASE_PAGES { - let releases = fetch(page).await?; - if releases.len() > RELEASES_PER_PAGE { - return None; + async fn download_inner(&self, service: &Service, release: VerifiedRelease) { + if service.manual_only { + self.release_snapshot(service, &release, State::Available) + .await; + return; + } + self.release_snapshot(service, &release, State::Downloading) + .await; + match service.updater.download(&release).await { + Ok(prepared) => { + self.0.releases.lock().await.prepared = Some(prepared.clone()); + match self.stage_prepared(prepared).await { + Ok(()) => self.release_snapshot(service, &release, State::Ready).await, + Err(error) => self.fail(error), + } + } + Err(_) => { + self.fail("Could not download or verify the update. Retry when you are ready.") + } + } + } + pub async fn set_auto_download(&self, enabled: bool) { + self.0.automatic.store(enabled, Ordering::Relaxed); + if enabled && self.snapshot().state == State::Available { + self.download().await; } - for release in &releases { - let Some(version) = agent_version(channel, release) else { - continue; + } + pub async fn later(&self) { + self.0.releases.lock().await.deferred = self.snapshot().version; + let mut snapshot = self.snapshot(); + snapshot.show_banner = false; + self.publish(snapshot); + } + /// Called only after the desktop's explicit task-stop confirmation. Returns + /// the verified installed path for GPUI's deliberate restart handoff. + pub async fn install(&self, api_url: String) -> Result, String> { + if self.0.cancel.is_cancelled() { + return Ok(None); + } + let Ok(_operation) = self.0.operation.try_lock() else { + return Ok(None); + }; + let result = self.install_inner(api_url).await; + if let Err(error) = &result { + self.fail(error.clone()); + } + result + } + async fn install_inner(&self, api_url: String) -> Result, String> { + let service = self.service().await?; + let prepared = self + .0 + .releases + .lock() + .await + .prepared + .clone() + .ok_or("No verified update is ready")?; + let mut installing = Snapshot::new(State::Installing, "Preparing to restart and update…"); + installing.version = Some(prepared.release.manifest.version.clone()); + installing.manual_download_url = manual_download_url(&prepared.release); + self.publish(installing); + let installed_path = service.installed_path.clone(); + let staging = prepared.clone(); + let mut releases = self.0.releases.lock().await; + let saved = releases.installation.take(); + let saved_lock = releases.install_lock.take(); + drop(releases); + let (lock, preparation) = tokio::task::spawn_blocking(move || { + let lock = match saved_lock { + Some(lock) => lock, + None => maple_agent_updater::installer::acquire_install_lock(&installed_path) + .map_err(|e| e.to_string())?, + }; + let preparation = match saved { + Some(preparation) => { + if let Err(error) = + maple_agent_updater::installer::revalidate(&preparation, &staging) + { + let _ = maple_agent_updater::installer::discard_preparation(&preparation); + return Err(error.to_string()); + } + preparation + } + None => { + maple_agent_updater::installer::prepare(&staging).map_err(|e| e.to_string())? + } }; - if version.cmp_precedence(current).is_gt() - && newest.as_ref().is_none_or(|best| &version > best) - { - newest = Some(version); + Ok::<_, String>((lock, preparation)) + }) + .await + .map_err(|_| "Could not prepare installation".to_string())??; + let fresh = match service.updater.revalidate(&prepared).await { + Ok(fresh) => fresh, + Err(_) => { + discard_preparation(preparation).await; + return Err("Could not revalidate the update. Maple is still running.".into()); + } + }; + match fresh { + Revalidation::Ready(_) => {} + Revalidation::Superseded(release) => { + discard_preparation(preparation).await; + drop(lock); + self.0.releases.lock().await.prepared = None; + service + .updater + .clear_prepared() + .await + .map_err(|e| e.to_string())?; + self.accept_release(service, release).await; + return Ok(None); + } + Revalidation::Withdrawn => { + discard_preparation(preparation).await; + drop(lock); + self.0.releases.lock().await.prepared = None; + self.0.releases.lock().await.release = None; + service + .updater + .clear_prepared() + .await + .map_err(|e| e.to_string())?; + self.publish(Snapshot::new( + State::Current, + "This update is no longer offered. Check again later.", + )); + return Ok(None); } } - if releases.len() < RELEASES_PER_PAGE { - return newest; + let recovery_api = api_url.clone(); + let updater = service.updater.clone(); + let result = tokio::task::spawn_blocking(move || { + let _lock = lock; + let result = crate::host::with_update_fence(&api_url, || { + updater.with_commit_guard(&prepared, || { + // replace performs the final payload/tree/codesign checks + // while both process fences and the commit guard are held. + let installed = maple_agent_updater::installer::replace(&preparation, &prepared).map_err(|e| e.to_string())?; + if installed.durability_warning { + log::warn!("The update was installed; filesystem synchronization did not complete. Restarting the installed application."); + } + Ok(installed.installed_path) + }).map_err(|error| error.to_string()) + }); + if result.is_err() { + let _ = maple_agent_updater::installer::discard_preparation(&preparation); + } + result + }).await.map_err(|_| "Installation did not complete; retry or reinstall Maple".to_string())?; + match result { + Ok(path) => Ok(Some(path)), + Err(error) => { + // An Err is guaranteed to precede the atomic swap. The host + // fence and application lock have both dropped before recovery. + if crate::host::recover_after_failed_update(&recovery_api) + .await + .is_err() + { + return Err(format!( + "{error}. Quit and reopen Maple to restore its host." + )); + } + Err(error) + } } } - log::debug!("update check: release pagination limit reached"); - None } -fn agent_version(channel: ReleaseChannel, release: &serde_json::Value) -> Option { - // Missing or malformed flags also fail closed. - if release["draft"].as_bool()? || release["prerelease"].as_bool()? != channel.prerelease { - return None; - } - let tag = release["tag_name"].as_str()?; - let version = Version::parse(tag.strip_prefix(channel.tag_prefix)?).ok()?; - if !version.pre.is_empty() || !version.build.is_empty() { - return None; - } - Some(version) +async fn discard_preparation(preparation: maple_agent_updater::installer::InstallPreparation) { + discard_preparation_with_lock(preparation, None).await; } -fn release_info(channel: ReleaseChannel, repo: &str, version: Version) -> Option { - if !valid_repo(repo) || !version.pre.is_empty() || !version.build.is_empty() { - return None; - } - Some(UpdateInfo { - url: format!( - "https://github.com/{repo}/releases/tag/{}{version}", - channel.tag_prefix - ), - version: version.to_string(), +async fn discard_preparation_with_lock( + preparation: maple_agent_updater::installer::InstallPreparation, + lock: Option, +) { + let _ = tokio::task::spawn_blocking(move || { + let _lock = lock; + maple_agent_updater::installer::discard_preparation(&preparation) }) + .await; } -#[cfg(test)] -mod tests { - use super::*; +fn manual_download_url(release: &VerifiedRelease) -> Option { + let artifact = if cfg!(target_os = "linux") { + release.manifest.downloads.linux_appimage.as_ref()? + } else { + &release.manifest.downloads.macos + }; + release + .origin + .join(&artifact.path) + .ok() + .map(|url| url.to_string()) +} - fn release(tag: &str) -> serde_json::Value { - serde_json::json!({ - "tag_name": tag, - // This untrusted link must have no influence on the banner. - "html_url": "https://example.test/untrusted/release", - "draft": false, - "prerelease": false, - }) - } +fn build_service() -> Result { + let (installed_path, manual_only) = installation()?; + let target = if cfg!(all(target_os = "macos", target_arch = "aarch64")) { + Target::DarwinAarch64 + } else if cfg!(all(target_os = "linux", target_arch = "x86_64")) { + Target::LinuxX86_64 + } else { + return Err("Updates are not supported on this platform".into()); + }; + let config = Config { + feed_url: crate::profile::UPDATE_FEED_URL + .ok_or("Updates not configured")? + .parse() + .map_err(|_| "Invalid packaged update configuration")?, + public_keys: crate::profile::UPDATE_PUBLIC_KEYS + .iter() + .map(|key| (*key).to_owned()) + .collect(), + current_version: env!("CARGO_PKG_VERSION") + .parse() + .map_err(|_| "Invalid app version")?, + target, + installed_path: installed_path.clone(), + state_dir: crate::backend::local_data_root().join("updates"), + }; + Ok(Service { + updater: Arc::new( + Updater::new(config).map_err(|_| "Invalid packaged update configuration")?, + ), + installed_path, + manual_only, + }) +} - async fn find(pages: Vec>, current: &str) -> Option { - find_newer_release(PROD_CHANNEL, &Version::parse(current).unwrap(), |page| { - std::future::ready(pages.get(page - 1).cloned()) - }) - .await +fn installation() -> Result<(PathBuf, bool), String> { + let exe = std::env::current_exe().map_err(|_| "Could not locate the installed app")?; + if cfg!(target_os = "macos") { + let app = exe + .ancestors() + .nth(3) + .filter(|path| path.extension().is_some_and(|e| e == "app")) + .ok_or("Automatic updates require an installed Maple app")?; + let app = std::fs::canonicalize(app).map_err(|_| "Could not locate the installed app")?; + let manual = manual_macos_path(&app) + || maple_agent_updater::installer::discover_installation(&exe, None).is_err(); + Ok((app, manual)) + } else { + // Linux stays notify-only until packaged host/update/relaunch gates are + // demonstrated. The launch helper fix is an independent prerequisite. + Ok((exe, true)) } +} +fn manual_macos_path(path: &Path) -> bool { + path.starts_with("/Volumes") + || path + .components() + .any(|component| component.as_os_str() == "AppTranslocation") +} - #[tokio::test] - async fn agent_feed_ignores_research_and_selects_highest_semantic_version() { - let newest = find( - vec![vec![ - release("v99.0.0"), - release("maple-agent-v1.9.0"), - release("maple-agent-v2.0.0"), - release("maple-agent-v1.10.0"), - release("maple-agent-v1.1.0"), - ]], - "1.0.0", - ) - .await - .expect("newer Agent release"); - let info = release_info(PROD_CHANNEL, DEFAULT_REPO, newest).unwrap(); - assert_eq!(info.version, "2.0.0"); +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn packaged_prod_needs_pins_and_dev_cannot_enable_updates() { assert_eq!( - info.url, - "https://github.com/MaplePrivacyLabs/Maple/releases/tag/maple-agent-v2.0.0" + disabled_reason("prod", &[], false), + Some("Updates not configured") ); - assert!( - find(vec![vec![release("v99.0.0")]], "0.1.0") - .await - .is_none() + assert!(disabled_reason("prod", &["key"], false).is_none()); + for profile in ["dev", "unpackaged"] { + assert!(disabled_reason(profile, &["key"], false).is_some()); + } + assert_eq!( + disabled_reason("prod", &["key"], true), + Some("Update checks disabled") ); } - - #[tokio::test] - async fn scans_later_pages_and_does_not_assume_release_date_order() { - let mut first = vec![release("v99.0.0"); RELEASES_PER_PAGE]; - first[0] = release("maple-agent-v1.5.0"); - let newest = find(vec![first, vec![release("maple-agent-v1.10.0")]], "1.0.0") - .await - .unwrap(); - assert_eq!(newest.to_string(), "1.10.0"); + #[test] + fn disk_image_and_translocated_apps_use_manual_installation() { + assert!(manual_macos_path(Path::new( + "/Volumes/Maple/Maple Agent.app" + ))); + assert!(manual_macos_path(Path::new( + "/private/var/folders/a/AppTranslocation/x/d/Maple Agent.app" + ))); + assert!(!manual_macos_path(Path::new( + "/Applications/Maple Agent.app" + ))); } - #[tokio::test] - async fn incomplete_or_failed_scan_does_not_advertise_partial_result() { - let full = vec![release("maple-agent-v2.0.0"); RELEASES_PER_PAGE]; - assert!(find(vec![full.clone()], "1.0.0").await.is_none()); - let mut calls = 0; - let newest = find_newer_release(PROD_CHANNEL, &Version::parse("1.0.0").unwrap(), |_| { - calls += 1; - std::future::ready(Some(full.clone())) - }) - .await; - assert!(newest.is_none()); - assert_eq!(calls, MAX_RELEASE_PAGES); - assert!( - find( - vec![vec![release("maple-agent-v2.0.0"); RELEASES_PER_PAGE + 1]], - "1.0.0" - ) - .await - .is_none() + async fn later_retains_verified_ready_action_without_a_banner() { + let controller = Controller::new(true); + let mut ready = Snapshot::new(State::Ready, "v0.0.2 is ready to install"); + ready.version = Some("0.0.2".into()); + ready.primary = Some(UpdateAction::Restart); + ready.primary_label = "Restart to Update"; + ready.show_banner = true; + controller.publish(ready); + controller.later().await; + let snapshot = controller.snapshot(); + assert_eq!(snapshot.state, State::Ready); + assert_eq!(snapshot.primary, Some(UpdateAction::Restart)); + assert!(!snapshot.show_banner); + assert_eq!( + controller.0.releases.lock().await.deferred.as_deref(), + Some("0.0.2") ); } - - #[tokio::test] - async fn same_older_and_build_only_versions_are_ignored() { - let releases = vec![release("maple-agent-v1.2.0"), release("maple-agent-v1.1.9")]; - assert!(find(vec![releases.clone()], "1.2.0").await.is_none()); - assert!(find(vec![releases], "1.2.0+local.5").await.is_none()); - } - - #[tokio::test] - async fn stable_release_updates_a_prerelease_client() { - let newest = find(vec![vec![release("maple-agent-v1.2.0")]], "1.2.0-beta.10") - .await - .unwrap(); - assert_eq!(newest.to_string(), "1.2.0"); - } - - #[test] - fn drafts_prereleases_and_invalid_flags_are_ignored() { - for (field, value) in [ - ("draft", serde_json::json!(true)), - ("prerelease", serde_json::json!(true)), - ("draft", serde_json::Value::Null), - ("prerelease", serde_json::json!("false")), - ] { - let mut body = release("maple-agent-v9.0.0"); - body[field] = value; - assert!(agent_version(PROD_CHANNEL, &body).is_none()); - } - } - #[test] - fn malformed_and_nonstable_tags_are_ignored_even_when_marked_stable() { - for tag in [ - "latest", - "1.2.3", - "v1.2.3", - "agent-v1.2.3", - "maple-agent-v1", - "maple-agent-v1.2", - "maple-agent-v1.2.3.4", - "maple-agent-v01.2.3", - "maple-agent-v1.2.3-beta.1", - "maple-agent-v1.2.3+build.5", - "maple-agent-v1.2.3 ", - " maple-agent-v1.2.3", - "maple-agent-vv1.2.3", - "maple-agent-v1.2.3/../../../other", - "maple-agent-v18446744073709551616.0.0", - ] { - assert!( - agent_version(PROD_CHANNEL, &release(tag)).is_none(), - "accepted {tag}" - ); - } - assert!(agent_version(PROD_CHANNEL, &serde_json::json!({})).is_none()); + fn failed_recheck_keeps_the_verified_manual_download_offer() { + let controller = Controller::new(false); + let mut downloading = Snapshot::new(State::Ready, "v0.0.2 is ready to install"); + downloading.version = Some("0.0.2".into()); + downloading.manual_download_url = + Some("https://updates.trymaple.ai/agent/stable/releases/0.0.2/Maple.dmg".into()); + controller.publish(downloading); + controller.begin_check(); + assert_eq!(controller.snapshot().state, State::Checking); + controller.fail("Could not check for updates"); + let snapshot = controller.snapshot(); + assert_eq!(snapshot.state, State::Error); + assert_eq!(snapshot.primary, Some(UpdateAction::Check)); + assert!(snapshot.manual_download_url.is_some()); + assert!(snapshot.show_banner); } - - #[test] - fn repository_override_cannot_escape_github_owner_repo() { - for repo in [DEFAULT_REPO, "some-owner/agent.test_repo"] { - assert!(valid_repo(repo)); - let info = release_info(PROD_CHANNEL, repo, Version::parse("1.2.3").unwrap()).unwrap(); - assert_eq!( - info.url, - format!("https://github.com/{repo}/releases/tag/maple-agent-v1.2.3") - ); - } - for repo in [ - "", - "MaplePrivacyLabs", - "/Maple", - "owner/", - "owner/../elsewhere", - "owner/..", - "owner/.", - "https://github.com/owner/repo", - "owner/repo?x=1", - "owner/repo#fragment", - "owner/repo%2felsewhere", - "owner/repo\\other", - "owner@evil.example/repo", - "owner/repo\n", - "-owner/repo", - "owner-/repo", - ] { - assert!(!valid_repo(repo), "accepted {repo}"); - assert!(release_info(PROD_CHANNEL, repo, Version::parse("1.2.3").unwrap()).is_none()); - } + #[tokio::test] + async fn repeated_checks_cannot_replace_an_active_transaction() { + let controller = Controller::new(false); + let _active = controller.0.operation.lock().await; + controller.publish(Snapshot::new(State::Installing, "Installing…")); + controller.check().await; + controller.download().await; + assert_eq!(controller.snapshot().state, State::Installing); + assert!(controller.0.service.get().is_none()); } - #[tokio::test] - async fn dev_feed_cannot_advertise_prod_or_research_and_requires_prerelease_flag() { - let mut dev = release("maple-agent-dev-v1.3.0"); - dev["prerelease"] = serde_json::json!(true); - let mut older_dev = release("maple-agent-dev-v1.2.0"); - older_dev["prerelease"] = serde_json::json!(true); - let mut prod_marked_dev = release("maple-agent-v9.0.0"); - prod_marked_dev["prerelease"] = serde_json::json!(true); - let releases = vec![ - release("v99.0.0"), - release("maple-agent-v99.0.0"), - release("maple-agent-dev-v99.0.0"), - prod_marked_dev, - older_dev, - dev.clone(), - ]; - let newest = find_newer_release(DEV_CHANNEL, &Version::parse("1.0.0").unwrap(), |_| { - std::future::ready(Some(releases.clone())) - }) - .await - .unwrap(); - assert_eq!(newest.to_string(), "1.3.0"); - let info = release_info(DEV_CHANNEL, DEFAULT_REPO, newest).unwrap(); - assert_eq!( - info.url, - "https://github.com/MaplePrivacyLabs/Maple/releases/tag/maple-agent-dev-v1.3.0" + async fn shutdown_prevents_new_network_or_install_transactions() { + let controller = Controller::new(true); + controller.publish(Snapshot::new(State::Idle, "Not checked yet")); + controller.shutdown(); + controller.clone().run().await; + controller.check().await; + controller.download().await; + assert!( + controller + .install("https://unused.invalid".into()) + .await + .unwrap() + .is_none() ); - assert!(agent_version(PROD_CHANNEL, &dev).is_none()); - for field in ["draft", "prerelease"] { - let mut malformed = dev.clone(); - malformed[field] = serde_json::Value::Null; - assert!(agent_version(DEV_CHANNEL, &malformed).is_none()); - } - for tag in [ - "maple-agent-dev-v1.3.0-beta.1", - "maple-agent-dev-v1.3.0+build.1", - ] { - let mut malformed = dev.clone(); - malformed["tag_name"] = serde_json::json!(tag); - assert!(agent_version(DEV_CHANNEL, &malformed).is_none()); - } + assert!(controller.0.service.get().is_none()); + assert_eq!(controller.snapshot().state, State::Idle); } - - #[test] - fn packaged_feed_ignores_inherited_repository_override() { - for configured in ["attacker/different-feed", "https://evil.example/releases"] { - assert_eq!( - configured_repo(true, Some(configured.into())).as_deref(), - Some(DEFAULT_REPO) - ); - } - assert_eq!( - configured_repo(false, Some("local-owner/agent".into())).as_deref(), - Some("local-owner/agent") - ); - assert!(configured_repo(false, Some("https://evil.example/releases".into())).is_none()); + #[tokio::test] + async fn disabled_controller_is_accurate_and_has_no_network_work() { + let controller = Controller::new(true); + controller.publish(Snapshot::new(State::Disabled, "Test disables updates")); + assert_eq!(controller.snapshot().state, State::Disabled); + controller.clone().run().await; + controller.check().await; + assert_eq!(controller.snapshot().state, State::Disabled); + assert!(controller.0.service.get().is_none()); } } diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index c2f3c97bd..136690730 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -19,10 +19,10 @@ startup 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. -| Profile | Application | macOS bundle ID | Configuration/data namespace | Update tags | +| Profile | Application | macOS bundle ID | Configuration/data namespace | Update delivery | | --- | --- | --- | --- | --- | -| Dev | Maple Agent Dev | `cloud.opensecret.maple.agent.dev` | `maple-agent-dev` | `maple-agent-dev-vX.Y.Z`, prerelease | -| Prod | Maple Agent | `cloud.opensecret.maple.agent` | `maple-agent-prod` | `maple-agent-vX.Y.Z`, stable | +| Dev | Maple Agent Dev | `cloud.opensecret.maple.agent.dev` | `maple-agent-dev` | Disabled; master Actions artifacts | +| Prod | Maple Agent | `cloud.opensecret.maple.agent` | `maple-agent-prod` | Signed Agent feed, after production keys are configured | The binary embeds its profile, hosted API/client/billing settings, and PCR environment. `--build-info` prints the public build identity as JSON without @@ -113,20 +113,21 @@ nix develop --no-update-lock-file . -c ./scripts/verify-release.sh dev dist/dev ``` Use `prod` for the production service profile. Official macOS signing is run -only by the trusted CI job using the existing `desktop-signing` GitHub +only by the trusted CI job using the separate `agent-desktop-signing` GitHub environment. Cargo and native compilation run before that job step receives Apple credentials, on a separate runner. Linux packaging needs no Apple credentials. ## Distribution and update boundaries -These builds are Actions downloads. They do not create GitHub Releases or -advance an updater feed. The application discovers only releases in its own -profile's tag namespace and opens their release page for a manual download; -it does not install an update. Dev and Prod reject each other's releases, and -Research's bare `vX.Y.Z` releases are excluded. - -A future Agent publisher must use namespaced tags and `make_latest: false`, -preserving Research's repository-wide latest release. Release publication and -any Research-to-Agent migration require their own explicit authorization. No -Research install is replaced, overwritten, or migrated by this workflow. +These builds remain Actions downloads and do not advance an updater feed. +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 +keys embedded in the selected Prod profile. Empty production keys disable update +traffic until owner setup is complete. See [updater activation](updates.md). + +Configure `agent-desktop-signing` and its master-only protection before merging +the workflow's environment change. Keep Research's signing environment, 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/docs/release-profiles.md b/apps/maple-agent/docs/release-profiles.md index 95d69335b..3bb8ef9e2 100644 --- a/apps/maple-agent/docs/release-profiles.md +++ b/apps/maple-agent/docs/release-profiles.md @@ -27,7 +27,8 @@ redirected through an inherited environment variable. This Agent profile does not change Research's Auth entry or the backend's default provider callbacks. Packaged builds ignore inherited `MAPLE_API_URL`, `MAPLE_BILLING_API_URL`, -`MAPLE_CLIENT_ID`, and `MAPLE_UPDATE_REPO` overrides. A build fails if a supplied +`MAPLE_CLIENT_ID` overrides. The updater feed and trusted keys are also immutable; +`MAPLE_UPDATE_REPO` is no longer supported. A build fails if a supplied `VITE_OPEN_SECRET_PCR_ENVIRONMENT` disagrees with its profile; the runtime crate bakes the required trust selection even when that variable is absent. Unknown or empty `MAPLE_RELEASE_PROFILE` values also fail the build. @@ -49,15 +50,27 @@ Debug. Release packaging accepts only Dev or Prod metadata. environment, initializing a GUI/backend, logging, or accessing state. Fields are `profile`, `display_name`, `bundle_id`, `data_namespace`, `api_url`, `billing_api_url`, `web_url`, `auth_origin`, `client_id`, `pcr_environment`, `version`, `git_revision`, -`source_sha`, `update_tag_prefix`, and `prerelease`. `git_revision` is the short +`source_sha`, `update_tag_prefix`, `prerelease`, `update_feed_url`, and +`update_public_keys`. `git_revision` is the short revision with a possible `-dirty` suffix; `source_sha` is the full source commit. Tarball/pure Nix builds without Git report `unknown` for unavailable revisions. -Update discovery remains a manual release-page banner. Prod accepts only -non-draft GitHub stable releases tagged `maple-agent-vMAJOR.MINOR.PATCH`. Dev -accepts only non-draft GitHub prereleases tagged -`maple-agent-dev-vMAJOR.MINOR.PATCH`. Both reject SemVer prerelease/build -suffixes, Research tags, and the other Agent channel. Packaged builds pin -`MaplePrivacyLabs/Maple`; unpackaged builds retain validated repository -overrides and the Prod tag namespace. `MAPLE_DISABLE_UPDATE_CHECK=1` remains -available for every profile. No update is downloaded or installed. +Packaged Prod builds use only the compiled Agent stable feed at +`https://updates.trymaple.ai/agent/stable/latest.json`. The profile contains no +production signing keys yet, so it reports **Updates not configured** and makes +no update requests until the separately approved key setup is complete. Active +trust configuration requires two distinct public keys; the backup supports +planned rotation and does not revoke a compromised accepted primary. + +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 +channels; they are not the updater's source of authority. + +Configured Prod checks after launch and approximately hourly, verifies signed +metadata and package contents, and downloads automatically by default. Users +can turn automatic downloads off and download explicitly. Installation waits +for **Restart to Update** and a warning that tasks and remote access stop. +Read-only/translocated apps and the currently gated Linux updater offer manual +download links from signed metadata. See [updater development and activation](updates.md) for +verification, recovery and the separate production activation boundary. diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md new file mode 100644 index 000000000..f6a56daaa --- /dev/null +++ b/apps/maple-agent/docs/updates.md @@ -0,0 +1,149 @@ +# Agent updates + +Packaged Prod builds use the Agent-only signed update service at +`https://updates.trymaple.ai/agent/stable/latest.json`. Dev and unpackaged +builds do not check that service. The production public-key list is intentionally +empty until reviewed owner setup; those builds show **Updates not configured** +and make no updater requests. Environment variables cannot supply a feed or +signing key. `MAPLE_DISABLE_UPDATE_CHECK=1` can only disable checks. + +This feature does not change Research's updater, update Worker, publisher, +release selection, signing configuration, or download paths. Agent publication +does not create GitHub Releases or tags and cannot change the repository's +latest Release. + +## User behavior + +The desktop checks at startup and about hourly, with jitter. Automatic download +is enabled by default and can be disabled in Settings. An authenticated update +is downloaded in the background, but replacement requires **Restart to Update** +and confirmation that active work will stop. **Later** hides the banner while +keeping the prepared update available in Settings. About, the native application +menu, and the tray provide update access. + +The installer holds the per-application lock while staging a verified bundle +beside the installed application and while that update is ready. It checks the +fresh signed offer again and holds the profile's existing startup and host ownership locks from host stop +through replacement. It checks the payload and staged tree again before macOS's +atomic bundle exchange. A previous bundle remains in the staging directory until +the new installed version starts successfully and validates its receipt. This +is crash recovery retention, not automatic rollback or permission to install an +older signed version. + +Read-only locations, disk images and translocated bundles offer a manual +download. Linux currently offers manual installation: the AppImage self-launch +repair is a prerequisite, and does not by itself prove safe replacement and +relaunch. Enabling Linux automatic installation requires the packaged gates +below. Unsupported architectures do not install another architecture's payload. + +## Wire and trust contract + +The response is an envelope with two fields: `manifest` is the exact JSON string +that was signed, and `signature` is a four-line, prehashed minisign signature. +The authenticated trusted comment is +`product=maple-agent channel=stable version=`. Clients verify a compiled +Agent public key before interpreting the manifest. Research keys are not used. + +The `maple-agent-update-v1` manifest identifies `maple-agent`, `stable`, a +canonical stable SemVer, a positive decimal build number, the source commit, +UTC publication date, bounded plain-text notes, updater artifacts, and manual +downloads. macOS arm64 uses `.app.tar.gz` for installation and `.dmg` for manual +download. Optional Linux x86_64 entries reference the same `.AppImage` in both +inventories. Each object has a signed path, byte size, and SHA-256. + +Paths stay under `/agent/stable/releases//`. Metadata is +bounded to 128 KiB, manifest text to 64 KiB, signature text to 4 KiB, release notes +to 16 KiB, and each artifact to 4 GiB. Download redirects admit only the two +compiled HTTPS origins `updates.trymaple.ai` and `updates.maple.ai`, on port 443; +requests have timeouts and no application cookies, +credentials, referrer, or inherited proxy. HTTP content decoding is disabled so +signed sizes and resume offsets refer to the same bytes. A partial response must +match the requested range, and the complete file is hashed before preparation. + +Updater state is private to the compiled profile. It records the highest verified +version and rejects older offers. A moved application keeps that high-water mark +and clears its old preparation binding. Restored preparations are authenticated +and rehashed; saved paths are not authority. Before installation a withdrawn or +changed offer clears preparation. A newer offer immediately starts the normal +check/download path. A malicious service can withhold a release; signed metadata +has no expiry, and this implementation does not claim freeze-attack prevention. + +macOS installation also requires the expected bundle ID +`cloud.opensecret.maple.agent`, Developer ID team `X773Y823TN`, signed version and +build, hardened code-signing identity, and a bundle tree matching the downloaded +archive. Extraction bounds entries, expanded bytes and path lengths; it rejects +path traversal, hard links, special files, duplicate paths and escaping or cyclic +symlinks while allowing internal framework links. No downloaded executable runs +before replacement. + +## Owner setup before activation + +Implementation PRs do not create infrastructure, production signing keys, +GitHub environments, routes, credentials, releases, or production publications. +Before enabling the feed, prepare and review these concrete operations: + +1. Create two dedicated Agent minisign key pairs, retain the backup offline, and + commit only the two reviewed public keys in `release-profiles.json`. Rebuild + the initial `0.0.1` package with those pins. Test keys must never become Prod + pins. Key rotation is a separately reviewed release operation. +2. Protect `agent-desktop-signing` for trusted master builds and supply its five + Apple signing/notarization secrets. Keep Research's `desktop-signing` + environment and its credentials/policies unchanged. +3. Provision the Agent R2 bucket and Worker. Bind only the Agent bucket. Create + the two HTTPS routes for `/agent/stable/*` and + `/download/agent/stable/*` as an owner operation. Keep routes out of routine + Worker deployment configuration so deploy tokens do not need route-write + authority. Configure HTTP-to-HTTPS handling in the zone. +4. Protect `agent-update-signing` and `agent-updates-production`, restrict them + to trusted master, and configure the independently scoped signing and R2 + credentials documented by the service. Publication must verify that the + selected artifact's embedded pins and trusted master both contain the chosen + public key; an environment variable is not a substitute for compiled trust. +5. Deploy with the narrowly scoped existing-Worker token and confirm the R2 + binding can be retained. Verify Agent activation leaves Research working, + and a later normal Research publication leaves Agent routes working. + +The service and publisher's own README documents exact commands and recovery. +Artifact builds remain separate from publication. Only final verified Prod +packages from the trusted desktop workflow may be published; selector jobs and +unsigned PR packages are not release evidence. Immutable version objects are +create-only and pointer promotion is conditional, so retries cannot overwrite +an existing version or race a later release. + +## Validation and release gates + +Run Agent checks through its pinned environment: + +```sh +cd apps/maple-agent +nix develop --no-update-lock-file -c just ci +``` + +Workflow and configuration changes also require the root pinned +`nix flake check --no-update-lock-file`. Focused core and installer tests cover +signature, schema, anti-rollback, interrupted downloads, saved-state tampering, +archive/path attacks, staging integrity, replacement receipts and lock exclusion. +They do not prove Developer ID distribution, deployed service behavior, or a +successful real package update. + +Before invites, publish an unadvertised signed `0.0.1`, then `0.0.2`, and run the +actual installed application's check, download, confirmation, host stop, swap +and relaunch through the production service. Verify active host clients cannot +start during installation, the new version works, and a failed or interrupted +attempt preserves a runnable installation. Keep version, build, source SHA, +workflow run, manifest, artifact hashes, OS, install location and observed result +with the rehearsal evidence. The invitation date slips if these gates fail. + +For Linux, first verify AppImage GUI, host, ACP and reopen behavior on the target +Ubuntu environment, including spaces in the path and the supported extraction +mode. Then verify a real AppImage N-to-N+1 replacement and relaunch with host +exclusion and failure recovery. Until that evidence exists, Linux automatic +installation stays disabled and Linux does not count as passing the preview's +updater gate. Do not infer package proof from a launcher fixture or Rust unit test. + +Recovery must preserve the signed high-water mark. Fix a bad offer by publishing +a new, higher version; do not rewrite an immutable version or lower the pointer +and expect clients to downgrade. Keep the previous installation until the new +startup checkpoint succeeds. If a disk image, permissions, signature, host stop, +or network check prevents installation, use the verified manual download path +or retry without weakening validation. diff --git a/apps/maple-agent/release-profiles.json b/apps/maple-agent/release-profiles.json index cb0be6b74..70e8604ac 100644 --- a/apps/maple-agent/release-profiles.json +++ b/apps/maple-agent/release-profiles.json @@ -10,6 +10,8 @@ "client_id": "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6", "pcr_environment": "development", "update_tag_prefix": "maple-agent-dev-v", + "update_feed_url": null, + "update_public_keys": [], "prerelease": true }, "prod": { @@ -23,6 +25,8 @@ "client_id": "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6", "pcr_environment": "production", "update_tag_prefix": "maple-agent-v", + "update_feed_url": "https://updates.trymaple.ai/agent/stable/latest.json", + "update_public_keys": [], "prerelease": false } } diff --git a/apps/maple-agent/release_profile.rs b/apps/maple-agent/release_profile.rs index efd189f16..f59c0f677 100644 --- a/apps/maple-agent/release_profile.rs +++ b/apps/maple-agent/release_profile.rs @@ -17,12 +17,19 @@ pub struct ReleaseProfile { pub client_id: String, pub pcr_environment: String, pub update_tag_prefix: String, + pub update_feed_url: Option, + pub update_public_keys: Vec, pub prerelease: bool, } pub fn profiles() -> Result, String> { let profiles: BTreeMap = serde_json::from_str(include_str!("release-profiles.json")).map_err(|e| e.to_string())?; + validate_profiles(&profiles)?; + Ok(profiles) +} + +fn validate_profiles(profiles: &BTreeMap) -> Result<(), String> { if profiles.len() != 2 || !profiles.contains_key("dev") || !profiles.contains_key("prod") { return Err("release-profiles.json must contain exactly dev and prod".into()); } @@ -74,6 +81,28 @@ pub fn profiles() -> Result, String> { "{channel} profile has invalid public service configuration" )); } + if profile.update_feed_url.as_deref() + != if channel == "prod" { + Some("https://updates.trymaple.ai/agent/stable/latest.json") + } else { + None + } + || (channel == "dev" && !profile.update_public_keys.is_empty()) + || !matches!(profile.update_public_keys.len(), 0 | 2) + || profile + .update_public_keys + .iter() + .enumerate() + .any(|(index, key)| { + key.trim().is_empty() + || key.len() > 1024 + || profile.update_public_keys[..index].contains(key) + }) + { + return Err(format!( + "{channel} profile has invalid updater configuration" + )); + } } if profiles["dev"].display_name == profiles["prod"].display_name || profiles["dev"].api_url == profiles["prod"].api_url @@ -82,7 +111,7 @@ pub fn profiles() -> Result, String> { { return Err("Dev and Prod must have distinct names and service endpoints".into()); } - Ok(profiles) + Ok(()) } pub fn select_profile( @@ -109,6 +138,10 @@ pub fn select_profile( // Hosted return is admitted only by packaged profiles. Local // environment overrides must not opt an unpackaged client into it. profile.auth_origin = None; + // Development binaries must never discover or install a Prod update, + // even if the selected packaged profile gains signing keys later. + profile.update_feed_url = None; + profile.update_public_keys.clear(); // Local/MDE builds keep the existing explicit PCR override. Invalid // values still fail runtime validation rather than being normalized. if let Some(pcr) = configured_pcr { @@ -177,6 +210,21 @@ pub fn emit(manifest_path: &str, helper_path: &str, app: bool) { constants.push_str(&format!( "pub const AUTH_ORIGIN: Option<&str> = {auth_origin};\n" )); + let update_feed_url = match profile.update_feed_url.as_deref() { + Some(url) => format!( + "Some({})", + serde_json::to_string(url).expect("profile update feed URL") + ), + None => "None".to_string(), + }; + constants.push_str(&format!( + "pub const UPDATE_FEED_URL: Option<&str> = {update_feed_url};\n" + )); + let public_keys = + serde_json::to_string(&profile.update_public_keys).expect("profile update keys"); + constants.push_str(&format!( + "pub const UPDATE_PUBLIC_KEYS: &[&str] = &{public_keys};\n" + )); constants.push_str(&format!( "pub const PRERELEASE: bool = {};\n", profile.prerelease @@ -212,6 +260,15 @@ mod tests { assert_ne!(dev.update_tag_prefix, prod.update_tag_prefix); assert!(dev.prerelease); assert!(!prod.prerelease); + assert_eq!(dev.update_feed_url, None); + assert!(dev.update_public_keys.is_empty()); + assert_eq!( + prod.update_feed_url.as_deref(), + Some("https://updates.trymaple.ai/agent/stable/latest.json") + ); + // The reviewed source may be built before production key setup. The + // updater fails closed until separately approved keys are pinned. + assert!(prod.update_public_keys.is_empty()); for profile in [dev, prod] { assert_ne!(profile.data_namespace, "maple-agent"); assert_ne!(profile.data_namespace, "maple-gpui"); @@ -238,5 +295,40 @@ mod tests { assert_eq!(profile.pcr_environment, "development"); assert_eq!(profile.bundle_id, "cloud.opensecret.maple.agent.debug"); assert_eq!(profile.auth_origin, None); + assert_eq!(profile.update_feed_url, None); + assert!(profile.update_public_keys.is_empty()); + } + + #[test] + fn updater_profile_rejects_feed_redirection_and_dev_keys() { + let original = profiles().unwrap(); + for channel in ["dev", "prod"] { + let mut profiles = original.clone(); + profiles.get_mut(channel).unwrap().update_feed_url = + Some("https://wrong.example/latest.json".into()); + assert!(validate_profiles(&profiles).is_err()); + } + let mut profiles = original.clone(); + profiles.get_mut("prod").unwrap().update_feed_url = None; + assert!(validate_profiles(&profiles).is_err()); + let mut profiles = original; + profiles.get_mut("dev").unwrap().update_public_keys = vec!["unapproved-key".into()]; + assert!(validate_profiles(&profiles).is_err()); + } + + #[test] + fn updater_profile_accepts_disabled_key_setup_and_rejects_empty_or_duplicate_keys() { + let original = profiles().unwrap(); + assert!(validate_profiles(&original).is_ok()); + for keys in [ + vec![" ".into()], + vec!["single".into()], + vec!["key".into(), "key".into()], + vec!["x".repeat(1025), "second".into()], + ] { + let mut profiles = original.clone(); + profiles.get_mut("prod").unwrap().update_public_keys = keys; + assert!(validate_profiles(&profiles).is_err()); + } } } diff --git a/apps/maple-agent/scripts/release-info.py b/apps/maple-agent/scripts/release-info.py index 5cfd15ffe..73af1c892 100755 --- a/apps/maple-agent/scripts/release-info.py +++ b/apps/maple-agent/scripts/release-info.py @@ -2,6 +2,7 @@ """Validate public release identity and create package metadata, never secrets.""" import argparse +import base64 import hashlib import json from pathlib import Path, PurePosixPath @@ -16,6 +17,7 @@ PUBLIC_FIELDS = ( "display_name", "bundle_id", "data_namespace", "api_url", "billing_api_url", "web_url", "auth_origin", "client_id", "pcr_environment", "update_tag_prefix", "prerelease", + "update_feed_url", "update_public_keys", ) BUILD_FIELDS = set(PUBLIC_FIELDS) | {"profile", "version", "git_revision", "source_sha"} @@ -24,6 +26,27 @@ def read_info(profile, path, source_sha=None): return validate_info(profile, json.loads(Path(path).read_text()), source_sha) +def validate_update_keys(keys): + if not isinstance(keys, list) or len(keys) not in (0, 2): + raise ValueError("update trust must contain zero or two distinct public keys") + public_points = set() + for key in keys: + if not isinstance(key, str) or len(key) > 1024: + raise ValueError("invalid public update key") + # Comments and key IDs do not make a duplicate public key a backup. + lines = key.strip().splitlines() + packet = lines[-1] if lines else "" + try: + decoded = base64.b64decode(packet, validate=True) + except ValueError as error: + raise ValueError("invalid public update key encoding") from error + if len(decoded) != 42 or decoded[:2] != b"Ed": + raise ValueError("invalid public update key packet") + if decoded[10:] in public_points: + raise ValueError("update trust requires distinct public keys") + public_points.add(decoded[10:]) + + def validate_info(profile, info, source_sha=None): profiles = json.loads((COMPONENT / "release-profiles.json").read_text()) if profile not in ("dev", "prod"): @@ -39,7 +62,15 @@ def validate_info(profile, info, source_sha=None): raise ValueError("binary prerelease marker must be a boolean") if info["auth_origin"] is not None and not isinstance(info["auth_origin"], str): raise ValueError("public auth origin must be a string or null") - if any(not isinstance(info[field], str) for field in BUILD_FIELDS - {"prerelease", "auth_origin"}): + if info["update_feed_url"] is not None and not isinstance(info["update_feed_url"], str): + raise ValueError("public update feed must be a string or null") + keys = info["update_public_keys"] + validate_update_keys(keys) + if profile == "dev" and (info["update_feed_url"] is not None or keys): + raise ValueError("Dev builds cannot enable the production updater") + if any(not isinstance(info[field], str) for field in BUILD_FIELDS - { + "prerelease", "auth_origin", "update_feed_url", "update_public_keys", + }): raise ValueError("public build identity fields must be strings") if not re.fullmatch(r"[0-9a-f]{40}", info["source_sha"]): raise ValueError("release binary must record a full source commit") diff --git a/apps/maple-agent/scripts/test_release_packaging.py b/apps/maple-agent/scripts/test_release_packaging.py index ca955803f..423da619e 100644 --- a/apps/maple-agent/scripts/test_release_packaging.py +++ b/apps/maple-agent/scripts/test_release_packaging.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 """Hermetic identity, artifact-integrity and native-signing boundary tests.""" +import base64 import importlib.util import io import json @@ -249,6 +250,30 @@ def test_metadata_cannot_carry_extra_environment_or_secret_fields(self): with self.assertRaises(ValueError): release_info.read_info("dev", self.path) + def test_update_feed_and_keys_cannot_drift_from_the_packaged_profile(self): + for field, value in ( + ("update_feed_url", "https://wrong.invalid/agent/stable/latest.json"), + ("update_public_keys", ["attacker-key"]), + ): + original = self.info[field] + self.info[field] = value + self.write_info() + with self.subTest(field=field), self.assertRaises(ValueError): + release_info.read_info("dev", self.path) + self.info[field] = original + + def test_backup_requires_a_distinct_public_key_not_a_changed_comment_or_id(self): + def key(point, key_id=b"12345678", comment="fixture"): + packet = base64.b64encode(b"Ed" + key_id + point).decode() + return f"untrusted comment: {comment}\n{packet}\n" + primary = key(bytes(range(32))) + release_info.validate_update_keys([]) + release_info.validate_update_keys([primary, key(bytes(reversed(range(32))))]) + for duplicate in [primary, key(bytes(range(32)), comment="backup"), + key(bytes(range(32)), key_id=b"87654321")]: + with self.subTest(duplicate=duplicate), self.assertRaisesRegex(ValueError, "distinct"): + release_info.validate_update_keys([primary, duplicate]) + def test_release_plist_has_identity_permissions_and_no_shell_environment(self): output = self.directory / "Info.plist" release_info.write_plist(self.info, output, "42") diff --git a/docs/app-signing-environments.md b/docs/app-signing-environments.md index 53f8b14e7..09b0115b1 100644 --- a/docs/app-signing-environments.md +++ b/docs/app-signing-environments.md @@ -6,6 +6,7 @@ Branch protection on `master` alone does not protect repository-level secrets. | Environment | Credentials | Jobs | | --- | --- | --- | +| `agent-desktop-signing` | `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_ID_PASSWORD`, `APPLE_TEAM_ID` | Agent macOS Dev/Prod packages from trusted master | | `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` | macOS/Linux master and release builds | | `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 | @@ -26,8 +27,11 @@ Pages/updater deployments. The same environments also serve owner-controlled `v*` release tags, so their signing jobs likewise have no separate approval prompt. Creating a release and controlling its distribution remain separate boundaries. -`Maple Agent Desktop Builds` also uses the existing `desktop-signing` environment -for its trusted `master` macOS Dev/Prod jobs. The same Developer ID publisher +`Maple Agent Desktop Builds` uses the separate `agent-desktop-signing` environment +for its trusted `master` macOS Dev/Prod jobs. Configure its master-only policy and +Apple credentials before merging that workflow change; it has no Research Tauri key. +Agent update-manifest signing and distribution use separate protected environments. +The same Developer ID publisher signs distinct Agent bundle identities; it does not replace Research's identity. Agent contributor packaging, Linux compilation, and macOS compilation receive no Apple or updater signing secrets. A fresh macOS packaging job downloads the diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 96b1858db..899734649 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -153,7 +153,7 @@ def test_only_trusted_master_can_receive_apple_signing_credentials(self): self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") jobs = config["jobs"] signed = jobs["macos"] - self.assertEqual(signed["environment"], "desktop-signing") + self.assertEqual(signed["environment"], "agent-desktop-signing") self.assertEqual(signed["runs-on"], "macos-26") condition = " ".join(signed["if"].split()) self.assertIn(self.MASTER_GUARD, condition) diff --git a/scripts/ci/test_signing_workflows.py b/scripts/ci/test_signing_workflows.py index 07957695e..e9fddce47 100644 --- a/scripts/ci/test_signing_workflows.py +++ b/scripts/ci/test_signing_workflows.py @@ -16,6 +16,10 @@ ROOT = Path(__file__).resolve().parents[2] ENVIRONMENTS = { + "agent-desktop-signing": { + "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_ID", + "APPLE_ID_PASSWORD", "APPLE_TEAM_ID", + }, "desktop-signing": { "TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_ID", @@ -40,7 +44,7 @@ } SIGNING_SECRETS = set.union(*ENVIRONMENTS.values()) CONSUMERS = { - "agent-desktop-build.yml": {"macos": "desktop-signing"}, + "agent-desktop-build.yml": {"macos": "agent-desktop-signing"}, "agent-updates-publish.yml": {"sign": "agent-update-signing"}, "desktop-build.yml": { "build-macos": "desktop-signing", "build-linux": "desktop-signing", @@ -58,6 +62,7 @@ "build-android": "android-signing", "build-ios": "apple-signing", }, "zapstore-publish.yml": {"publish": "zapstore-publishing"}, + # Preserve the original credential diagnostics and Research environments. "signing-credentials-check.yml": { name: name for name in ( "desktop-signing", "apple-signing", "android-signing", From 439cdbff02a23cac64cae60e2a6e728eb3cac121 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:30:58 +0000 Subject: [PATCH 05/12] fix(agent-updates): verify pinned keys and locked publication retries Accept raw and commented public keys with the real minisign verifier. Reuse byte-identical immutable objects before attempting a conditional write, preserving create-only race handling and fail-closed storage errors. Add real-CLI pointer retry/repair regressions and offline R2 signing and transport coverage. Pin the publisher GitHub CLI, run preparation in its pinned shell, and follow profile/package-contract changes in delivery checks. Configured-bucket lock and pointer-race behavior remains an activation rehearsal gate. Validation: isolated delivery checks passed (94 publisher tests, 6 workflow tests, 10 Worker/workerd tests); selector tests and independent changed-workflow actionlint/ShellCheck passed. Full root flake check and normal hook were attempted but stalled in this host's actionlint/ShellCheck integration and unrelated iOS fixture. Commit hook skipped only after equivalent focused checks; hosted repository checks remain required. --- .github/workflows/agent-updates-publish.yml | 7 +- .github/workflows/agent-updates-tests.yml | 4 + flake.nix | 2 +- scripts/ci/hook_change_detection.py | 6 + scripts/ci/test_agent_updates_workflows.py | 10 + scripts/ci/test_hook_change_detection.py | 5 + services/agent-updates/README.md | 4 + services/agent-updates/publisher/README.md | 19 +- services/agent-updates/publisher/publisher.py | 11 +- .../agent-updates/publisher/test_minisign.py | 84 ++++++ .../agent-updates/publisher/test_publisher.py | 50 ++++ .../agent-updates/publisher/test_r2_store.py | 272 ++++++++++++++++++ 12 files changed, 466 insertions(+), 8 deletions(-) create mode 100644 services/agent-updates/publisher/test_r2_store.py diff --git a/.github/workflows/agent-updates-publish.yml b/.github/workflows/agent-updates-publish.yml index cf832fe0d..7d3e9a30a 100644 --- a/.github/workflows/agent-updates-publish.yml +++ b/.github/workflows/agent-updates-publish.yml @@ -66,10 +66,10 @@ jobs: RELEASE_NOTES: ${{ inputs.notes }} INCLUDE_LINUX: ${{ inputs.include_linux }} run: | - set -euo pipefail + nix develop --no-update-lock-file .#ci -c bash -euo pipefail <<'PREPARE' linux_args=() if [ "$INCLUDE_LINUX" = true ]; then linux_args=(--linux); fi - nix develop --no-update-lock-file .#ci -c python3 services/agent-updates/publisher/publisher.py trust \ + python3 services/agent-updates/publisher/publisher.py trust \ --run-id "$BUILD_RUN_ID" "${linux_args[@]}" --output trust.json gh run download "$BUILD_RUN_ID" --repo "$GITHUB_REPOSITORY" \ --name "$(jq -er .artifacts.macos trust.json)" --dir packages/macos @@ -80,9 +80,10 @@ jobs: linux_args=(--linux packages/linux) fi printf '%s' "$RELEASE_NOTES" > notes.txt - nix develop --no-update-lock-file .#ci -c python3 services/agent-updates/publisher/publisher.py prepare \ + python3 services/agent-updates/publisher/publisher.py prepare \ --run-id "$BUILD_RUN_ID" --version "$RELEASE_VERSION" --build "$(jq -er .build trust.json)" \ --macos packages/macos "${linux_args[@]}" --notes-file notes.txt --public-key public-key.pub --output bundle + PREPARE - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: agent-update-prepared-${{ github.run_id }} diff --git a/.github/workflows/agent-updates-tests.yml b/.github/workflows/agent-updates-tests.yml index 68e0c38ec..d92e7e286 100644 --- a/.github/workflows/agent-updates-tests.yml +++ b/.github/workflows/agent-updates-tests.yml @@ -12,6 +12,8 @@ on: - "scripts/ci/agent-updates.sh" - "scripts/ci/test_agent_updates_workflows.py" - "scripts/ci/_common.sh" + - "apps/maple-agent/scripts/release-info.py" + - "apps/maple-agent/release-profiles.json" - "flake.nix" - "flake.lock" pull_request: @@ -21,6 +23,8 @@ on: - "scripts/ci/agent-updates.sh" - "scripts/ci/test_agent_updates_workflows.py" - "scripts/ci/_common.sh" + - "apps/maple-agent/scripts/release-info.py" + - "apps/maple-agent/release-profiles.json" - "flake.nix" - "flake.lock" diff --git a/flake.nix b/flake.nix index d15d5d13a..114a47e39 100644 --- a/flake.nix +++ b/flake.nix @@ -198,7 +198,7 @@ ]; # Frontend scripts run tsc and Vite through their Node shebangs; pin # that runtime instead of relying on a hosted runner's global Node. - ciPackages = [ rustupShim pkgs.nodejs ] ++ commonPackages; + ciPackages = [ rustupShim pkgs.nodejs pkgs.gh ] ++ commonPackages; linuxTauriPackages = with pkgs; diff --git a/scripts/ci/hook_change_detection.py b/scripts/ci/hook_change_detection.py index 711a5dc45..59a525ee6 100644 --- a/scripts/ci/hook_change_detection.py +++ b/scripts/ci/hook_change_detection.py @@ -51,6 +51,10 @@ AGENT_PREFIX = "apps/maple-agent/" AGENT_INERT_PREFIXES = ("docs/", ".githooks/") +AGENT_PUBLICATION_INPUTS = frozenset({ + "apps/maple-agent/scripts/release-info.py", + "apps/maple-agent/release-profiles.json", +}) SDK_TS_PREFIXES = ("sdk/src/",) SDK_TS_FILES = frozenset( @@ -135,6 +139,8 @@ def classify_path(path: str) -> frozenset[str]: relative = path.removeprefix(AGENT_PREFIX) if relative.startswith(AGENT_INERT_PREFIXES): return frozenset() + if path in AGENT_PUBLICATION_INPUTS: + return frozenset({"agent", "agent_updates"}) return frozenset({"agent"}) if path in SDK_SHARED_FILES: diff --git a/scripts/ci/test_agent_updates_workflows.py b/scripts/ci/test_agent_updates_workflows.py index 9bebeda1c..2c0f92c6c 100644 --- a/scripts/ci/test_agent_updates_workflows.py +++ b/scripts/ci/test_agent_updates_workflows.py @@ -20,6 +20,15 @@ def workflow(name): class AgentUpdatesWorkflows(unittest.TestCase): + def test_delivery_checks_follow_imported_profile_and_package_contracts(self): + config = workflow("agent-updates-tests.yml") + for event in ("push", "pull_request"): + self.assertTrue({ + "apps/maple-agent/scripts/release-info.py", + "apps/maple-agent/release-profiles.json", + "services/agent-updates/**", + } <= set(config["on"][event]["paths"])) + def test_manual_master_only_and_separate_authority(self): config = workflow("agent-updates-publish.yml") self.assertEqual(set(config["on"]), {"workflow_dispatch"}) @@ -62,6 +71,7 @@ def test_preparation_verifies_provenance_before_any_secrets(self): source = "\n".join(step.get("run", "") for step in verify["steps"]) self.assertIn("AGENT_UPDATES_ENABLED", str(verify)) self.assertIn('"$ACTIVATED" = true', source) + self.assertIn("nix develop --no-update-lock-file .#ci -c bash -euo pipefail <<'PREPARE'", source) self.assertIn("publisher.py trust", source) self.assertIn("publisher.py prepare", source) self.assertIn("--public-key public-key.pub", source) diff --git a/scripts/ci/test_hook_change_detection.py b/scripts/ci/test_hook_change_detection.py index 8dc310244..06c8d7b1b 100644 --- a/scripts/ci/test_hook_change_detection.py +++ b/scripts/ci/test_hook_change_detection.py @@ -77,6 +77,11 @@ def test_agent_component(self) -> None: with self.subTest(path=path): self.assert_selects([path], "agent") + def test_agent_publication_inputs_select_both_consumers_without_research(self) -> None: + for path in ("apps/maple-agent/scripts/release-info.py", "apps/maple-agent/release-profiles.json"): + with self.subTest(path=path): + self.assert_selects([path], "agent", "agent_updates") + def test_sdk_components(self) -> None: self.assert_selects(["sdk/rust/src/client.rs"], "sdk_rust") self.assert_selects(["sdk/rust/tests/crypto.rs"], "sdk_rust") diff --git a/services/agent-updates/README.md b/services/agent-updates/README.md index c910d35bf..b91d44bff 100644 --- a/services/agent-updates/README.md +++ b/services/agent-updates/README.md @@ -123,6 +123,10 @@ An unadvertised signed `0.0.1` to `0.0.2` rehearsal through real packages, feed, keys and UI is required before invitations. Include Linux only after its own packaged launch/update/relaunch gates pass. This service does not prove those client gates or authorize a deploy/publication. +The separately authorized bucket rehearsal must also demonstrate identical +retries under its actual release-prefix lock policy, differing-byte refusal, +missing-object repair and conditional pointer-race rejection. Mock storage and +request-signature tests do not establish those live R2 properties. Pointer rollback changes discovery, not installed apps. Install-time client revalidation is required. Freeze/replay protection is limited in v1; a compromised diff --git a/services/agent-updates/publisher/README.md b/services/agent-updates/publisher/README.md index 9351d846a..ab6de5145 100644 --- a/services/agent-updates/publisher/README.md +++ b/services/agent-updates/publisher/README.md @@ -36,6 +36,9 @@ is insufficient, while an unrelated Dev failure does not block a qualified Prod package. The latest job inventory retains successful producer jobs when only failed consumers were rerun. `prepare` repeats this check and verifies every final downloaded file's GitHub attestation against the exact source SHA, master ref and signer workflow. +The self-hosted-runner denial applies to the job generating that attestation. +Final package attestations are generated on GitHub-hosted jobs; their input +binaries are compiled on the workflow's Blacksmith runners. Package metadata, public Prod profile, inventory, notarization results and streaming checksums are validated before copying the binary payloads into the bundle. The macOS archive's bounded regular `Info.plist` member is read without extraction to @@ -63,8 +66,10 @@ reservation and snapshots the current feed's ETag. It creates immutable payloads and the bounded private audit sidecars, verifies each by reading its content back, then creates the complete signed `stable/releases/V/manifest.json` and promotes `stable/latest.json` using `If-Match` (or `If-None-Match: *` for the first release). -Existing immutable content is streamed and reused only if its bytes match. A -partially uploaded release resumes without replacing completed objects. A +Existing immutable content is streamed and reused only if its bytes match, +before attempting a write against a possibly locked release object. Missing +objects still use create-only writes; a competing creation must match exactly. +A partially uploaded release resumes without replacing completed objects. A concurrent pointer change stops promotion. Re-running an already published exact release verifies the objects, repairs missing objects with create-only writes and leaves the pointer unchanged; a different envelope at the same or lower version is @@ -109,7 +114,15 @@ nix develop --no-update-lock-file .#ci -c python3 -m unittest discover \ The fake-store tests cover interrupted writes, reservation/envelope reuse, immutable conflicts, pointer races and already-published recovery. Their synthetic signatures exercise envelope plumbing without making a cryptographic claim. -The pinned-shell minisign tests verify the shared public wire fixture, generate +The pinned-shell minisign tests verify the shared public wire fixture and an +existing signed pointer using raw or commented public-key files and pinned-key +rotation, including an identical publication retry and missing-object repair. They generate disposable offline keys only inside a private temporary directory, sign a reserved manifest, and exercise verification, tampering and deterministic signing retries. +Mock-transport tests exercise fixed-time SigV4 vectors, signed conditional headers, +bounded reads, streamed uploads and hashes, HTTP failures and redirect refusal. +These offline checks do not establish the configured R2 bucket's behavior. Before +activation, its rehearsal must include locked-prefix identical reservation and +publication retries, a different-byte immutable conflict, missing-object repair, +and a competing pointer promotion that leaves the competing bytes unchanged. No production credentials, resources or live release are used by these tests. diff --git a/services/agent-updates/publisher/publisher.py b/services/agent-updates/publisher/publisher.py index f9423dfc1..f14f1dd39 100644 --- a/services/agent-updates/publisher/publisher.py +++ b/services/agent-updates/publisher/publisher.py @@ -216,7 +216,10 @@ def minisign_verify(data, public_key): signature = Path(temporary) / "signature.minisig" message.write_bytes(envelope["manifest"].encode("utf-8")) signature.write_text(envelope["signature"], encoding="utf-8") - result = subprocess.run(["minisign", "-V", "-H", "-q", "-m", str(message), "-x", str(signature), "-p", str(public_key)], stdin=subprocess.DEVNULL, capture_output=True, timeout=30) + # Configured/public pinned keys may be raw base64 or a commented file. + # minisign -p only accepts the latter, while -P accepts the normalized + # public key packet in either case. No secret key is passed in argv. + result = subprocess.run(["minisign", "-V", "-H", "-q", "-m", str(message), "-x", str(signature), "-P", public_key_text(public_key)], stdin=subprocess.DEVNULL, capture_output=True, timeout=30) need(result.returncode == 0, "minisign verification failed") return manifest @@ -644,6 +647,12 @@ def immutable_put(store, key, data, content_type="application/octet-stream", max digest, size = hash_file(data, maximum) else: digest, size = hashlib.sha256(data).hexdigest(), len(data) + # A locked release object may reject an attempted write before evaluating + # its conditional header. Reuse matching bytes without any write attempt. + # Missing/different objects still require a create-only PUT; the second + # comparison handles a writer winning the race after this first read. + if store.same(key, digest, size): + return try: store.put(key, data, condition=("If-None-Match", "*"), content_type=content_type) except StoreError as error: diff --git a/services/agent-updates/publisher/test_minisign.py b/services/agent-updates/publisher/test_minisign.py index 2e0379e0d..64cd4ac09 100644 --- a/services/agent-updates/publisher/test_minisign.py +++ b/services/agent-updates/publisher/test_minisign.py @@ -1,5 +1,6 @@ """Verify the shared public wire fixture with the pinned real minisign CLI.""" +import base64 import json import hashlib from pathlib import Path @@ -7,6 +8,7 @@ import subprocess import tempfile import unittest +from unittest.mock import patch import publisher @@ -27,6 +29,84 @@ def test_shared_client_fixture_verifies_prehashed_with_exact_comment(self): self.assertEqual(envelope["manifest"].encode(), (FIXTURES / "manifest.json").read_bytes()) self.assertEqual(envelope["signature"].encode(), (FIXTURES / "manifest.minisig").read_bytes()) + def test_raw_public_key_file_verifies_with_the_real_cli(self): + with tempfile.TemporaryDirectory(prefix="maple-agent-public-key-test-") as temporary: + key = Path(temporary) / "one-line.pub" + key.write_text(publisher.public_key_text(self.key) + "\n") + self.assertEqual(publisher.minisign_verify(self.data, key)["version"], "0.0.2") + + def test_existing_signed_pointer_rotation_retry_and_missing_object_repair(self): + class Store: + def __init__(self): + self.objects = {} + self.writes = [] + + def get(self, key, maximum=publisher.MAX_JSON): + if key not in self.objects: + return None + data = self.objects[key] + return data, '"' + hashlib.sha256(data).hexdigest() + '"' + + def same(self, key, digest, size): + value = self.objects.get(key) + return value is not None and len(value) == size and hashlib.sha256(value).hexdigest() == digest + + def put(self, key, data, *, condition, content_type): + current = self.get(key) + if condition == ("If-None-Match", "*"): + if current is not None: + raise publisher.StoreError(412) + elif current is None or condition != ("If-Match", current[1]): + raise publisher.StoreError(412) + self.writes.append(key) + self.objects[key] = data.read_bytes() if isinstance(data, Path) else data + + with tempfile.TemporaryDirectory(prefix="maple-agent-pointer-test-") as temporary: + root = Path(temporary) + bundle = root / "bundle" + bundle.mkdir() + (bundle / "manifest.json").write_bytes((FIXTURES / "manifest.json").read_bytes()) + (bundle / "envelope.json").write_bytes(self.data) + # Match the workflow variable's raw one-line public-key input. + (bundle / "public-key.pub").write_text(publisher.public_key_text(self.key) + "\n") + payloads = bundle / "payloads" + payloads.mkdir() + for name in ("Maple-Agent.app.tar.gz", "Maple-Agent.dmg"): + (payloads / name).write_bytes(b"signed payload fixture bytes") + metadata = bundle / "metadata" + metadata.mkdir() + inventory = {} + for name in sorted({"provenance.json"} | publisher.MACOS_METADATA_NAMES): + data = ("offline public signing fixture: " + name + "\n").encode() + (metadata / name).write_bytes(data) + inventory[name] = {"sha256": hashlib.sha256(data).hexdigest(), "size": len(data)} + (bundle / "metadata-inventory.json").write_bytes(publisher.encode_json(inventory)) + profile = root / "apps/maple-agent/release-profiles.json" + profile.parent.mkdir(parents=True) + # The first valid public key does not sign this envelope, exercising + # the pinned-key fallback used during rotation with the real CLI. + other_key = base64.b64encode(b"Ed" + bytes(40)).decode() + profile.write_bytes(publisher.encode_json({"prod": { + "update_feed_url": publisher.FEED_URL, + "update_public_keys": [other_key, publisher.public_key_text(self.key)], + }})) + store = Store() + with patch.object(publisher, "ROOT", root): + publisher.reserve(bundle, store, self.key) + self.assertEqual(publisher.publish(bundle, store, self.key), "published") + store.writes.clear() + self.assertEqual(publisher.publish(bundle, store, self.key), "already-published") + self.assertEqual(store.writes, []) + missing = "stable/releases/0.0.2/Maple-Agent.app.tar.gz" + del store.objects[missing] + self.assertEqual(publisher.publish(bundle, store, self.key), "already-published") + self.assertEqual(store.writes, [missing]) + self.assertEqual(store.objects["stable/latest.json"], self.data) + tampered = json.loads(self.data) + tampered["manifest"] = tampered["manifest"].replace("A test update.", "A fake update.") + with self.assertRaisesRegex(publisher.PublishError, "not trusted"): + publisher.verify_current(publisher.encode_json(tampered), self.key) + def test_tampered_signed_bytes_and_comment_are_rejected(self): envelope = json.loads(self.data) envelope["manifest"] = envelope["manifest"].replace("A test update.", "A fake update.") @@ -48,6 +128,10 @@ def put(self, key, data, *, condition, content_type): def get(self, key, maximum=publisher.MAX_JSON): return (self.objects[key], '"fixture-etag"') if key in self.objects else None + def same(self, key, digest, size): + data = self.objects.get(key) + return data is not None and len(data) == size and hashlib.sha256(data).hexdigest() == digest + # These disposable, offline test keys never leave the private tempdir. # Production key generation is an owner operation outside this tool. with tempfile.TemporaryDirectory(prefix="maple-agent-minisign-test-") as temporary: diff --git a/services/agent-updates/publisher/test_publisher.py b/services/agent-updates/publisher/test_publisher.py index 9469bacab..058069ff8 100644 --- a/services/agent-updates/publisher/test_publisher.py +++ b/services/agent-updates/publisher/test_publisher.py @@ -536,6 +536,56 @@ def test_identical_published_release_is_verified_no_op(self): self.release_key("agent.app.tar.gz"), self.release_key("agent.dmg"), } <= {call[1] for call in self.store.calls if call[0] == "same"}) + def test_identical_locked_release_retry_never_attempts_an_overwrite(self): + self.reserve() + self.publish() + self.store.calls.clear() + original_put = self.store.put + + def locked_put(key, data, **kwargs): + if key in self.store.objects: + raise publisher.StoreError(403) + return original_put(key, data, **kwargs) + + with patch.object(self.store, "put", side_effect=locked_put): + publisher.reserve(self.bundle, self.store) + self.assertEqual(self.publish(), "already-published") + self.assertFalse(any(call[0] == "put" for call in self.store.calls)) + missing = self.release_key("agent.app.tar.gz") + del self.store.objects[missing] + self.assertEqual(self.publish(), "already-published") + self.assertEqual([call[1] for call in self.store.calls if call[0] == "put"], [missing]) + self.assertEqual(self.store.revisions["stable/latest.json"], 1) + + def test_immutable_create_race_reuses_only_identical_bytes(self): + key, data = self.release_key("race.bin"), b"release fixture" + original_put = self.store.put + for winning in (data, b"different content"): + with self.subTest(winning=winning): + self.store.objects.pop(key, None) + def racing_put(name, value, **kwargs): + self.store.set(name, winning) + return original_put(name, value, **kwargs) + with patch.object(self.store, "put", side_effect=racing_put): + if winning == data: + publisher.immutable_put(self.store, key, data) + else: + with self.assertRaisesRegex(publisher.PublishError, "immutable object differs"): + publisher.immutable_put(self.store, key, data) + self.assertEqual(self.store.objects[key], winning) + + def test_immutable_transport_or_authorization_failure_never_becomes_success(self): + for status in (403, 409, 500): + with self.subTest(status=status), patch.object(self.store, "put", side_effect=publisher.StoreError(status)): + with self.assertRaises(publisher.StoreError) as error: + publisher.immutable_put(self.store, self.release_key("missing.bin"), b"fixture") + self.assertEqual(error.exception.status, status) + with patch.object(self.store, "same", side_effect=publisher.StoreError(403)), \ + patch.object(self.store, "put") as write: + with self.assertRaises(publisher.StoreError): + publisher.immutable_put(self.store, self.release_key("missing.bin"), b"fixture") + write.assert_not_called() + def test_identical_pointer_repairs_missing_artifact_without_rewriting_pointer(self): self.reserve() self.publish() diff --git a/services/agent-updates/publisher/test_r2_store.py b/services/agent-updates/publisher/test_r2_store.py new file mode 100644 index 000000000..4ab26e941 --- /dev/null +++ b/services/agent-updates/publisher/test_r2_store.py @@ -0,0 +1,272 @@ +"""Offline S3 signing and transport boundary tests; no bucket or credentials. + +The fixed signing vectors use AWS's published example access/secret key pair, +an explicitly written canonical request, and R2's ``auto`` signing region. +The expected digests and signatures are literals derived independently of +R2Store. Mock openers consume file streams without making network requests. +""" + +from datetime import datetime, timezone +from email.message import Message +import hashlib +import hmac +import importlib.util +import io +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import urllib.error +from urllib.response import addinfourl + + +SPEC = importlib.util.spec_from_file_location("agent_r2_publisher", Path(__file__).with_name("publisher.py")) +publisher = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(publisher) + +ENDPOINT = "https://example.r2.cloudflarestorage.com" +ACCESS_KEY = "AKIDEXAMPLE" +SECRET_KEY = "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY" +KEY = "stable/releases/0.0.2/agent.app.tar.gz" +PAYLOAD = b"test payload" +PAYLOAD_SHA = "813ca5285c28ccee5cab8b10ebda9c908fd6d78ed9dc94cc65ea6cb67a7f13ae" +FIXED_TIME = datetime(2026, 10, 22, 3, 4, 5, tzinfo=timezone.utc) +CANONICAL_CREATE = ( + "PUT\n/agent-updates/stable/releases/0.0.2/agent.app.tar.gz\n\n" + "content-length:12\n" + "content-type:application/octet-stream\n" + "host:example.r2.cloudflarestorage.com\n" + "if-none-match:*\n" + "x-amz-content-sha256:813ca5285c28ccee5cab8b10ebda9c908fd6d78ed9dc94cc65ea6cb67a7f13ae\n" + "x-amz-date:20261022T030405Z\n\n" + "content-length;content-type;host;if-none-match;x-amz-content-sha256;x-amz-date\n" + "813ca5285c28ccee5cab8b10ebda9c908fd6d78ed9dc94cc65ea6cb67a7f13ae" +) +CREATE_CANONICAL_SHA = "b862834295f34ed57bb95db458cb4798c77e2f103834662c801972ff78d52f06" +CREATE_SIGNATURE = "f4bdc56620f024468268b63c28ce74c623374223950021e28dca54adae79565a" + + +class Response(io.BytesIO): + def __init__(self, data=b"", headers=None): + super().__init__(data) + self.headers = headers or {} + self.read_sizes = [] + self.bytes_read = 0 + + def read(self, size=-1): + self.read_sizes.append(size) + data = super().read(size) + self.bytes_read += len(data) + return data + + +class OfflineOpener: + """Always intercept requests, including when an assertion fails.""" + + def __init__(self): + self.requests = [] + self.responses = [] + self.error = None + self.capture = None + + def open(self, request, timeout): + self.requests.append((request, timeout)) + if self.error is not None: + raise self.error + if self.capture is not None: + self.capture(request) + return self.responses.pop(0) if self.responses else Response() + + +def headers(request): + return {name.lower(): value for name, value in request.header_items()} + + +class R2StoreTests(unittest.TestCase): + def setUp(self): + self.opener = OfflineOpener() + self.opener_factory = self.enterContext(patch.object(publisher.urllib.request, "build_opener", return_value=self.opener)) + self.clock = self.enterContext(patch.object(publisher, "datetime")) + self.clock.now.return_value = FIXED_TIME + self.store = publisher.R2Store(ENDPOINT, "agent-updates", ACCESS_KEY, SECRET_KEY) + + def test_fixed_create_only_sigv4_vector(self): + # Capture the actual final string-to-sign as well as the wire header. + # Expectations are fixed, not generated by the production signer. + original_hmac = hmac.new + with patch.object(publisher.hmac, "new", wraps=original_hmac) as signer: + self.store.put(KEY, PAYLOAD, condition=("If-None-Match", "*")) + request, timeout = self.opener.requests[0] + actual = headers(request) + self.assertEqual(hashlib.sha256(CANONICAL_CREATE.encode()).hexdigest(), CREATE_CANONICAL_SHA) + self.assertEqual(signer.call_args.args[1], ( + "AWS4-HMAC-SHA256\n20261022T030405Z\n20261022/auto/s3/aws4_request\n" + CREATE_CANONICAL_SHA + ).encode()) + self.assertEqual(actual["authorization"], ( + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261022/auto/s3/aws4_request, " + "SignedHeaders=content-length;content-type;host;if-none-match;x-amz-content-sha256;x-amz-date, " + "Signature=" + CREATE_SIGNATURE + )) + self.assertEqual(actual["if-none-match"], "*") + self.assertEqual(actual["x-amz-content-sha256"], PAYLOAD_SHA) + self.assertEqual(actual["content-length"], "12") + self.assertEqual(request.full_url, ENDPOINT + "/agent-updates/" + KEY) + self.assertEqual(request.get_method(), "PUT") + self.assertEqual(request.data, PAYLOAD) + self.assertEqual(timeout, 120) + self.clock.now.assert_called_once_with(timezone.utc) + + def test_fixed_pointer_compare_and_swap_sigv4_vector(self): + self.store.put("stable/latest.json", PAYLOAD, condition=("If-Match", '"old-etag"'), content_type="application/json") + actual = headers(self.opener.requests[0][0]) + self.assertEqual(actual["if-match"], '"old-etag"') + self.assertNotIn("if-none-match", actual) + self.assertEqual(actual["authorization"], ( + "AWS4-HMAC-SHA256 Credential=AKIDEXAMPLE/20261022/auto/s3/aws4_request, " + "SignedHeaders=content-length;content-type;host;if-match;x-amz-content-sha256;x-amz-date, " + "Signature=dad3f950c5030af4557aa5a4c05452af3d7351eeaf4f19898fb49e3e5342f462" + )) + + def test_file_put_streams_signed_size_and_sha256(self): + data = b"bounded offline upload\n" * 100000 + digest = hashlib.sha256(data).hexdigest() + uploaded = bytearray() + source = [] + + def consume(request): + self.assertNotIsInstance(request.data, (bytes, bytearray)) + source.append(request.data) + self.assertFalse(request.data.closed) + while block := request.data.read(65536): + uploaded.extend(block) + + self.opener.capture = consume + with tempfile.TemporaryDirectory() as temporary: + path = Path(temporary) / "agent.app.tar.gz" + path.write_bytes(data) + self.store.put(KEY, path, condition=("If-None-Match", "*")) + actual = headers(self.opener.requests[0][0]) + self.assertEqual(uploaded, data) + self.assertTrue(source[0].closed) + self.assertEqual(actual["content-length"], str(len(data))) + self.assertEqual(actual["x-amz-content-sha256"], digest) + self.assertIn("content-length;content-type;host;if-none-match;x-amz-content-sha256;x-amz-date", actual["authorization"]) + + def test_get_preserves_quoted_etag_case_insensitively(self): + response = Response(PAYLOAD, {"eTaG": '"opaque-multipart-3"'}) + self.opener.responses.append(response) + self.assertEqual(self.store.get(KEY), (PAYLOAD, '"opaque-multipart-3"')) + request, _ = self.opener.requests[0] + self.assertEqual(request.get_method(), "GET") + self.assertIsNone(request.data) + self.assertEqual(headers(request)["x-amz-content-sha256"], hashlib.sha256(b"").hexdigest()) + self.assertTrue(response.closed) + + def test_get_rejects_missing_or_oversized_etag(self): + for response_headers in ({}, {"ETag": "x" * 256}): + with self.subTest(headers=response_headers): + self.opener.responses.append(Response(PAYLOAD, response_headers)) + with self.assertRaisesRegex(publisher.PublishError, "no valid ETag"): + self.store.get(KEY) + + def test_head_returns_headers_without_reading_a_body(self): + response = Response(b"body must never be read", {"Content-Length": "999999999"}) + self.opener.responses.append(response) + self.assertEqual(self.store.request("HEAD", KEY, maximum=1), (b"", {"Content-Length": "999999999"})) + self.assertEqual(self.opener.requests[0][0].get_method(), "HEAD") + self.assertEqual(response.read_sizes, []) + self.assertTrue(response.closed) + + def test_buffered_response_is_bounded_and_closed(self): + response = Response(b"1234567890", {"ETag": '"version"'}) + self.opener.responses.append(response) + with self.assertRaisesRegex(publisher.PublishError, "oversized R2 response"): + self.store.get(KEY, maximum=4) + self.assertEqual(response.read_sizes, [5]) + self.assertEqual(response.bytes_read, 5) + self.assertTrue(response.closed) + + def test_stream_hash_accepts_exact_bound_without_buffering_entire_object(self): + data = b"large fixture\n" * 170000 + response = Response(data) + self.opener.responses.append(response) + self.assertEqual(self.store.stream_hash(KEY, maximum=len(data)), (hashlib.sha256(data).hexdigest(), len(data))) + self.assertGreater(len(response.read_sizes), 2) + self.assertTrue(all(0 < size <= publisher.CHUNK for size in response.read_sizes)) + self.assertTrue(response.closed) + + def test_stream_hash_stops_after_one_byte_beyond_bound(self): + response = Response(b"0123456789" * 100) + self.opener.responses.append(response) + with self.assertRaisesRegex(publisher.PublishError, "oversized artifact"): + self.store.stream_hash(KEY, maximum=7) + self.assertEqual(response.read_sizes, [8]) + self.assertEqual(response.bytes_read, 8) + self.assertTrue(response.closed) + + def test_get_and_same_treat_only_http_404_as_missing(self): + self.opener.error = urllib.error.HTTPError(ENDPOINT, 404, "not found", {}, None) + self.assertIsNone(self.store.get(KEY)) + self.assertFalse(self.store.same(KEY, PAYLOAD_SHA, len(PAYLOAD))) + with self.assertRaises(publisher.StoreError) as raised: + self.store.request("HEAD", KEY) + self.assertEqual(raised.exception.status, 404) + + def test_http_conflict_and_forbidden_preserve_status_without_error_body(self): + for status in (412, 403): + with self.subTest(status=status): + self.opener.error = urllib.error.HTTPError(ENDPOINT, status, "private upstream diagnostic", {}, None) + with self.assertRaises(publisher.StoreError) as raised: + self.store.put(KEY, PAYLOAD, condition=("If-None-Match", "*")) + self.assertEqual(raised.exception.status, status) + self.assertEqual(str(raised.exception), f"R2 request failed (HTTP {status})") + with self.assertRaises(publisher.StoreError) as raised: + self.store.get(KEY) + self.assertEqual(raised.exception.status, status) + + def test_transport_errors_are_bounded_and_do_not_expose_upstream_diagnostics(self): + for error in (OSError("private path or credential"), urllib.error.URLError("private upstream URL")): + with self.subTest(error=type(error).__name__): + self.opener.error = error + with self.assertRaises(publisher.PublishError) as raised: + self.store.get(KEY) + self.assertEqual(str(raised.exception), "R2 transport failed") + + def test_redirect_handler_refuses_following_all_supported_redirects(self): + handler = self.opener_factory.call_args.args[0] + if isinstance(handler, type): + handler = handler() + self.assertIsInstance(handler, publisher.NoRedirect) + for status in (301, 302, 303, 307, 308): + with self.subTest(status=status): + requests = [] + + class OfflineHTTPS(publisher.urllib.request.BaseHandler): + def https_open(self, request): + requests.append(request.full_url) + response_headers = Message() + response_headers["Location"] = "https://other.example/secret" + response = addinfourl(io.BytesIO(b""), response_headers, request.full_url, status) + response.msg = "Redirect" + return response + + # Exercise urllib's real redirect/error processing. There is + # no network-capable handler in this director, even on failure. + opener = publisher.urllib.request.OpenerDirector() + for component in (OfflineHTTPS(), handler, publisher.urllib.request.HTTPErrorProcessor(), publisher.urllib.request.HTTPDefaultErrorHandler()): + opener.add_handler(component) + self.store.opener = opener + with self.assertRaises(publisher.StoreError) as raised: + self.store.get(KEY) + self.assertEqual(raised.exception.status, status) + self.assertEqual(requests, [ENDPOINT + "/agent-updates/" + KEY]) + + def test_invalid_object_keys_never_reach_transport(self): + for key in ("/absolute", "stable//latest.json", "../secret", "stable/%2fsecret", "stable/key?query", "stable/key#fragment"): + with self.subTest(key=key), self.assertRaisesRegex(publisher.PublishError, "invalid R2 object key"): + self.store.get(key) + self.assertEqual(self.opener.requests, []) + + +if __name__ == "__main__": + unittest.main() From c222aa92fe96ab659ce5208f1400727d1f0bb1e5 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 06:53:34 +0000 Subject: [PATCH 06/12] fix(agent): harden updater installation and restart recovery Keep committed updates restartable with process-lifetime host fencing and retired backend admission. Preflight native destination permissions, preserve shared installation exclusion across accounts, and release owned installer locks explicitly. Scan actual Prod archives with the installer parser before final upload or attestation and omit extended tar metadata. Add native ACL/flags, descriptor-lifetime, controller teardown, failed relaunch, and signed-manual-fallback regressions. Pinned Rust/Clippy 1.98 full Agent just ci passed all four feature matrices and workspace/headless tests: 569 desktop, 46 updater, 112 headless. Also passed 80 packaging tests, the full root flake check and actual archive metadata compatibility checks. The normal hook was attempted but its host actionlint/ShellCheck integration stalled. Full root flake validation and standalone checks of the new workflow run blocks and shell scripts passed; bypass only that duplicate hook invocation. Signed live N-to-N+1 rehearsal remains a separate release gate. --- .github/workflows/agent-desktop-build.yml | 12 + apps/maple-agent/app/src/backend.rs | 187 ++++++++- apps/maple-agent/app/src/desktop.rs | 159 +++++++- apps/maple-agent/app/src/host.rs | 122 ++++-- apps/maple-agent/app/src/update.rs | 376 +++++++++++++++--- .../examples/validate-release-archive.rs | 67 ++++ .../maple-agent-updater/src/installer.rs | 376 ++++++++++++++++-- apps/maple-agent/docs/desktop-builds.md | 33 +- apps/maple-agent/docs/updates.md | 22 +- apps/maple-agent/scripts/macos-release-app.sh | 11 +- .../scripts/test_release_packaging.py | 58 ++- .../scripts/verify-updater-archive.sh | 28 ++ scripts/ci/test_agent_workflows.py | 19 +- 13 files changed, 1324 insertions(+), 146 deletions(-) create mode 100644 apps/maple-agent/crates/maple-agent-updater/examples/validate-release-archive.rs create mode 100755 apps/maple-agent/scripts/verify-updater-archive.sh diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index 4b75cb89c..3527e6f66 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -164,6 +164,9 @@ jobs: path: apps/maple-agent/target/release - name: Verify prebuilt profile and source before signing run: nix develop --no-update-lock-file . -c ./scripts/verify-prebuilt-release.sh "$MAPLE_RELEASE_PROFILE" + - name: Build trusted installer archive scanner + if: ${{ matrix.variant == 'prod' }} + run: nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive - name: Sign and notarize Agent packages run: nix develop --no-update-lock-file . -c ./scripts/package-release.sh "$MAPLE_RELEASE_PROFILE" env: @@ -172,6 +175,9 @@ jobs: APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + - name: Scan actual Prod updater archive with installer parser + if: ${{ matrix.variant == 'prod' }} + run: nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh "dist/$MAPLE_RELEASE_PROFILE" - name: Attest final macOS packages uses: actions/attest@281a49d4cbb0a72c9575a50d18f6deb515a11deb with: @@ -221,8 +227,14 @@ jobs: path: apps/maple-agent/target/release - name: Verify prebuilt profile and source run: nix develop --no-update-lock-file . -c ./scripts/verify-prebuilt-release.sh "$MAPLE_RELEASE_PROFILE" + - name: Build trusted installer archive scanner + if: ${{ matrix.variant == 'prod' }} + run: nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive - name: Package unsigned Agent preview run: nix develop --no-update-lock-file . -c ./scripts/package-release.sh "$MAPLE_RELEASE_PROFILE" --unsigned + - name: Scan actual Prod updater archive with installer parser + if: ${{ matrix.variant == 'prod' }} + run: nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh "dist/$MAPLE_RELEASE_PROFILE" --unsigned - name: Verify unsigned package profile and hashes run: nix develop --no-update-lock-file . -c ./scripts/verify-release.sh "$MAPLE_RELEASE_PROFILE" "dist/$MAPLE_RELEASE_PROFILE" --unsigned - name: Upload Agent packages diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index cc503191b..c5eb08fa2 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -11,9 +11,18 @@ pub(crate) mod local; use crate::host::{RemoteClient, protocol::Request}; use maple_agent::agent::*; use serde::{Serialize, de::DeserializeOwned}; -use std::{collections::HashMap, future::Future, sync::Arc}; +use std::{ + collections::HashMap, + future::Future, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; use tokio::{runtime::Runtime, sync::mpsc}; +const RETIRED_AFTER_UPDATE: &str = "Maple has been updated. Quit and reopen Maple to continue."; + pub struct AgentBackend { runtime: tokio::runtime::Handle, owned_runtime: Option, @@ -21,6 +30,7 @@ pub struct AgentBackend { api_url: String, controller: Option>, remote_host: Option, + retired_after_update: Arc, } impl Drop for AgentBackend { @@ -177,6 +187,7 @@ impl AgentBackend { api_url, controller: None, remote_host: None, + retired_after_update: Arc::new(AtomicBool::new(false)), }) } @@ -191,20 +202,55 @@ impl AgentBackend { api_url, controller: None, remote_host: None, + retired_after_update: Arc::new(AtomicBool::new(false)), }) } pub async fn remote(self: &Arc, peer: String) -> Result, String> { - let client = RemoteClient::connect_remote(&self.api_url, peer.clone()).await?; + self.with_remote_connection(peer.clone(), || async { + RemoteClient::connect_remote(&self.api_url, peer) + .await + .map(BackendClient::Host) + }) + .await + } + + async fn with_remote_connection( + self: &Arc, + peer: String, + connect: impl FnOnce() -> F, + ) -> Result, String> + where + F: Future>, + { + self.ensure_active()?; + let client = connect().await?; + self.ensure_active()?; Ok(Arc::new(Self { runtime: self.runtime.clone(), owned_runtime: None, - client: BackendClient::Host(client), + client, api_url: self.api_url.clone(), controller: Some(self.clone()), remote_host: Some(peer), + retired_after_update: self.retired_after_update.clone(), })) } + + /// Once the installed application has been replaced, this process must not + /// submit work even if the desktop's restart helper fails. Remote views and + /// their local controller share this terminal gate; cleanup can still spawn. + pub fn retire_after_update(&self) { + self.retired_after_update.store(true, Ordering::Release); + } + + fn ensure_active(&self) -> Result<(), String> { + if self.retired_after_update.load(Ordering::Acquire) { + Err(RETIRED_AFTER_UPDATE.into()) + } else { + Ok(()) + } + } pub fn remote_host(&self) -> Option<&str> { self.remote_host.as_deref() } @@ -224,6 +270,7 @@ impl AgentBackend { method: &str, params: impl Serialize, ) -> Result { + self.ensure_active()?; // Speech capture/playback and account UI belong to the controller. // All task operations use the selected host's separate connection. if let Some(controller) = &self.controller @@ -268,6 +315,7 @@ impl AgentBackend { request: AgentSendMessageRequest, id: String, ) -> Result { + self.ensure_active()?; match &self.client { BackendClient::Host(client) => { client @@ -292,6 +340,7 @@ impl AgentBackend { self.runtime.spawn(future) } pub async fn take_events(&self) -> Option> { + self.ensure_active().ok()?; match &self.client { BackendClient::Host(client) => client.take_events().await, #[cfg(test)] @@ -323,6 +372,9 @@ impl AgentBackend { pub fn detach(self: &Arc) -> tokio::task::JoinHandle<()> { let backend = self.clone(); self.spawn(async move { + if backend.ensure_active().is_err() { + return; + } match &backend.client { BackendClient::Host(client) => client.detach().await, #[cfg(test)] @@ -370,6 +422,7 @@ impl AgentBackend { }); } pub fn begin_integration_setup(&self, id: &str) -> Result { + self.ensure_active()?; if self.remote_host.is_some() { return Err("Open Maple on the execution computer to grant native permissions.".into()); } @@ -486,6 +539,134 @@ pub(crate) async fn dispatch( mod tests { use super::*; + fn fixture() -> Arc { + Arc::new(AgentBackend::for_test("http://127.0.0.1:1".into(), String::new()).unwrap()) + } + + fn in_process_client(controller: &AgentBackend) -> BackendClient { + let _runtime = controller.runtime.enter(); + let mut backend = LocalBackend::new(controller.api_url.clone(), String::new()).unwrap(); + backend.ui_fixture = true; + BackendClient::InProcess(Box::new(backend)) + } + + #[test] + fn retired_frontend_rejects_rpc_send_and_native_permissions_but_can_run_cleanup() { + struct MustNotSerialize; + impl Serialize for MustNotSerialize { + fn serialize(&self, _: S) -> Result { + panic!("retired requests must not reach serialization or dispatch") + } + } + + let backend = fixture(); + let runtime = backend.runtime_handle(); + runtime + .block_on(backend.call::<()>("cancel_oauth", ())) + .unwrap(); + backend.retire_after_update(); + backend.retire_after_update(); + runtime.block_on(async { + assert_eq!( + backend.call::<()>("cancel_oauth", MustNotSerialize).await, + Err(RETIRED_AFTER_UPDATE.into()) + ); + assert_eq!( + backend + .send_message_identified( + "fixture-user", + AgentSendMessageRequest { + session_id: "fixture-session".into(), + text: "must not be dispatched".into(), + model: None, + context_limit: None, + vision_capable: false, + steer: false, + queue_id: None, + attachments: Vec::new(), + }, + "fixture-send".into(), + ) + .await, + Err(RETIRED_AFTER_UPDATE.into()) + ); + assert_eq!( + backend.begin_integration_setup("fixture-integration").err(), + Some(RETIRED_AFTER_UPDATE.into()) + ); + assert!(backend.take_events().await.is_none()); + assert_eq!( + backend.spawn(async { "cleanup completed" }).await.unwrap(), + "cleanup completed" + ); + }); + } + + #[test] + fn remote_and_controller_share_terminal_update_retirement() { + let controller = fixture(); + let runtime = controller.runtime_handle(); + runtime.block_on(async { + let remote = controller + .with_remote_connection("fixture-peer".into(), || async { + Ok(in_process_client(&controller)) + }) + .await + .unwrap(); + let other_remote = controller + .with_remote_connection("other-fixture-peer".into(), || async { + Ok(in_process_client(&controller)) + }) + .await + .unwrap(); + remote.call::<()>("cancel_oauth", ()).await.unwrap(); + remote.retire_after_update(); + for backend in [&controller, &remote, &other_remote] { + assert_eq!( + backend.call::<()>("cancel_oauth", ()).await, + Err(RETIRED_AFTER_UPDATE.into()) + ); + // Controller-routed operations must be denied too. + assert_eq!( + backend.account("fixture-user").await.err(), + Some(RETIRED_AFTER_UPDATE.into()) + ); + } + }); + } + + #[test] + fn retirement_rejects_remote_connection_before_connect_and_after_pending_connect() { + let controller = fixture(); + let runtime = controller.runtime_handle(); + runtime.block_on(async { + let client = in_process_client(&controller); + let result = controller + .with_remote_connection("fixture-peer".into(), || async { + // Simulate installation committing while connection setup + // is in flight. No active remote facade may be published. + controller.retire_after_update(); + Ok(client) + }) + .await; + assert_eq!(result.err(), Some(RETIRED_AFTER_UPDATE.into())); + + let connected = AtomicBool::new(false); + let result = controller + .with_remote_connection("fixture-peer".into(), || async { + connected.store(true, Ordering::Relaxed); + Err("connection must not be attempted".into()) + }) + .await; + assert_eq!(result.err(), Some(RETIRED_AFTER_UPDATE.into())); + assert!(!connected.load(Ordering::Relaxed)); + assert_eq!( + controller.remote("fixture-peer".into()).await.err(), + Some(RETIRED_AFTER_UPDATE.into()) + ); + }); + } + #[tokio::test] async fn final_frontend_reference_can_be_dropped_by_an_async_callback() { let backend = tokio::task::spawn_blocking(|| { diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index 8ae98cc53..11f631806 100644 --- a/apps/maple-agent/app/src/desktop.rs +++ b/apps/maple-agent/app/src/desktop.rs @@ -2,7 +2,7 @@ //! event pump that forwards agent service events into the active chat //! screen. Compiled only with the `desktop` feature. -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use gpui::{ App, Bounds, Context, Entity, Pixels, Render, Window, WindowBounds, WindowOptions, actions, @@ -150,6 +150,12 @@ impl MapleApp { } fn set_update(&mut self, snapshot: crate::update::Snapshot, cx: &mut Context) { + if self.updater.restart_path().is_some() { + // No old chat/settings controls remain actionable after the host + // stop and replacement. Backend admission is retired independently. + cx.notify(); + return; + } if let Some(chat) = &self.local_chat { chat.update(cx, |chat, cx| chat.set_update(snapshot.clone(), cx)); } @@ -188,12 +194,21 @@ impl MapleApp { cx: &mut Context, ) { use crate::update::UpdateAction; + if self.updater.restart_path().is_some() && action != UpdateAction::Relaunch { + return; + } if action == UpdateAction::ManualDownload { if let Some(url) = self.updater.snapshot().manual_download_url { cx.open_url(&url); } return; } + if action == UpdateAction::Relaunch { + if let Some(path) = self.updater.restart_path() { + restart_installed_update(path, cx); + } + return; + } if action == UpdateAction::Restart { if self.restart_prompt_pending || self.updater.snapshot().state != crate::update::State::Ready @@ -206,11 +221,11 @@ impl MapleApp { &["Later", "Restart to Update"], cx); let controller = self.updater.clone(); let backend = self.backend.clone(); - let api_url = backend.api_url().to_owned(); + let install_backend = backend.clone(); let bridge = cx.spawn(async move |this, cx| { let installed = if answer.await == Ok(1) { backend - .spawn(async move { controller.install(api_url).await }) + .spawn(async move { controller.install(install_backend).await }) .await .ok() .and_then(Result::ok) @@ -221,13 +236,7 @@ impl MapleApp { let _ = this.update(cx, |this, cx| { this.restart_prompt_pending = false; if let Some(path) = installed { - this.updater.shutdown(); - persist_window_state(); - if let Some(state) = cx.try_global::() { - state.quit_stop_attempted.set(true); - } - cx.set_restart_path(path); - cx.restart(); + restart_installed_update(path, cx); } }); }); @@ -247,7 +256,7 @@ impl MapleApp { UpdateAction::AutomaticDownloads(enabled) => { controller.set_auto_download(enabled).await } - UpdateAction::Restart | UpdateAction::ManualDownload => {} + UpdateAction::Restart | UpdateAction::Relaunch | UpdateAction::ManualDownload => {} } })); } @@ -939,11 +948,51 @@ impl Render for MapleApp { // on transcript/composer via `typography::chat_reading`. .text_sm() .child(titlebar) - .child(match &self.screen { - Screen::Restoring => restoring_view().into_any_element(), - Screen::Login(login) => login.clone().into_any_element(), - Screen::Chat(chat) => chat.clone().into_any_element(), - Screen::Settings(screen) => screen.clone().into_any_element(), + .child(if self.updater.restart_path().is_some() { + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .child( + div() + .max_w(px(520.)) + .flex() + .flex_col() + .gap_4() + .p_6() + .child(div().text_xl().child("Update installed")) + .child(self.updater.snapshot().label) + .child( + div() + .flex() + .gap_3() + .child( + ui::widgets::primary_button("restart-installed-update") + .child("Restart Maple") + .on_click(cx.listener(|this, _, window, cx| { + this.update_action( + crate::update::UpdateAction::Relaunch, + window, + cx, + ); + })), + ) + .child( + ui::widgets::secondary_button("quit-installed-update") + .child("Quit Maple") + .on_click(|_, _, cx| quit_app(cx)), + ), + ), + ) + .into_any_element() + } else { + match &self.screen { + Screen::Restoring => restoring_view().into_any_element(), + Screen::Login(login) => login.clone().into_any_element(), + Screen::Chat(chat) => chat.clone().into_any_element(), + Screen::Settings(screen) => screen.clone().into_any_element(), + } }) } } @@ -962,8 +1011,23 @@ impl gpui::Global for DesktopLifecycle {} /// Recheck at the final effect, including completions/prompts admitted before /// installation began. Every explicit Quit path goes through this boundary. fn may_finish_quit(cx: &App) -> bool { - !cx.try_global::() - .is_some_and(|state| state.updater.snapshot().state == crate::update::State::Installing) + !cx.try_global::().is_some_and(|state| { + state.updater.restart_path().is_none() + && state.updater.snapshot().state == crate::update::State::Installing + }) +} + +fn restart_installed_update(path: PathBuf, cx: &mut App) { + persist_window_state(); + if let Some(state) = cx.try_global::() { + // The old profile host was already stopped before replacement. Never + // issue old-build stop/detach RPCs against a newly started host. + state.quit_stop_attempted.set(true); + } + cx.set_restart_path(path); + // This API returns no result. RestartRequired remains accurate and exposes + // Retry/normal Quit if GPUI cannot spawn its relaunch helper. + cx.restart(); } fn finish_quit(cx: &mut App) { @@ -979,6 +1043,11 @@ fn quit_app(cx: &mut App) { finish_quit(cx); return; }; + if state.updater.restart_path().is_some() { + state.quit_stop_attempted.set(true); + finish_quit(cx); + return; + } if state.updater.snapshot().state == crate::update::State::Installing { show_update_about(cx); return; @@ -1057,6 +1126,11 @@ pub(crate) fn close_window(window: &mut Window, cx: &mut App) { finish_quit(cx); return; }; + if state.updater.restart_path().is_some() { + state.quit_stop_attempted.set(true); + finish_quit(cx); + return; + } if state.updater.snapshot().state == crate::update::State::Installing { drop(window.prompt( gpui::PromptLevel::Info, @@ -1817,6 +1891,55 @@ mod tests { }); } + #[gpui::test] + async fn failed_restart_handoff_keeps_retry_and_normal_quit_available( + cx: &mut gpui::TestAppContext, + ) { + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()).unwrap()); + let updater = crate::update::Controller::new(false); + let path = PathBuf::from("/Applications/Maple Agent.app"); + backend.retire_after_update(); + updater.set_test_installed(path.clone()); + cx.update(|cx| { + cx.set_global(DesktopLifecycle { + backend, + updater: updater.clone(), + remote_enabled: Arc::new(std::sync::atomic::AtomicBool::new(true)), + can_background: true, + closing: Default::default(), + quit_stop_attempted: Default::default(), + }) + }); + // The test platform returns from restart without quitting, matching + // the observable result of a native helper-spawn failure. + for _ in 0..2 { + let restarted = cx.expect_restart(); + cx.update(|cx| restart_installed_update(updater.restart_path().unwrap(), cx)); + assert_eq!(restarted.await.unwrap(), Some(path.clone())); + cx.update(|cx| { + assert!(may_finish_quit(cx)); + assert_eq!( + updater.snapshot().state, + crate::update::State::RestartRequired + ); + assert_eq!( + updater.snapshot().primary, + Some(crate::update::UpdateAction::Relaunch) + ); + assert!(cx.global::().quit_stop_attempted.get()); + }); + } + cx.update(|cx| { + // Even with remote access enabled, normal Quit needs no old-build + // stop RPC and does not enter the remote-stop waiting state. + quit_app(cx); + assert!(!cx.global::().closing.get()); + assert!(may_finish_quit(cx)); + }); + } + fn project_shell(cx: &mut gpui::TestAppContext) -> Entity { cx.executor().allow_parking(); let backend = diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index 4e0dd9846..ffdbc452c 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -845,41 +845,42 @@ pub fn stop(api_url: &str) -> Result<(), String> { }) } -/// Run an already verified replacement while this profile cannot start a host. +/// Holds this profile's startup admission and host ownership through replacement +/// and the old desktop's entire restart handoff, including helper-spawn failure. +/// The new process acquires these normally after the old process exits. +#[cfg(feature = "desktop")] +pub struct HostUpdateFence { + // Release ownership before admission if a pre-commit attempt fails. + _ownership: std::fs::File, + _startup: std::fs::File, +} + +/// Stop the current host and reserve this profile against queued/direct starts. /// /// Call from a blocking task, never the UI or an async executor thread. The caller /// also owns the application-path installation lock and revalidates staging in -/// `replace`. File guards live until replacement finishes, including when the -/// async caller drops its blocking-task handle. Normal host startup already owns +/// `replace`. Retain the returned guards after commit until process exit; drop +/// them before recovering from a pre-commit failure. Normal host startup owns /// `startup.lock` while waiting for the child's handshake; the child must not try /// to acquire that same lock a second time. #[cfg(feature = "desktop")] -pub fn with_update_fence( - api_url: &str, - replace: impl FnOnce() -> Result, -) -> Result { +pub fn acquire_update_fence(api_url: &str) -> Result { let endpoint = ipc::Endpoint::new(api_url)?; - with_update_fence_endpoint( - &endpoint, - Duration::from_secs(45), - || { - let runtime = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|_| "Could not prepare update shutdown".to_string())?; - runtime.block_on(request_stop_endpoint(&endpoint)) - }, - replace, - ) + acquire_update_fence_endpoint(&endpoint, Duration::from_secs(45), || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|_| "Could not prepare update shutdown".to_string())?; + runtime.block_on(request_stop_endpoint(&endpoint)) + }) } #[cfg(feature = "desktop")] -fn with_update_fence_endpoint( +fn acquire_update_fence_endpoint( endpoint: &ipc::Endpoint, timeout: Duration, stop: impl FnOnce() -> Result<(), String>, - replace: impl FnOnce() -> Result, -) -> Result { +) -> Result { let startup = endpoint.lock("startup.lock")?; wait_update_lock(&startup, timeout, "Another Maple host is starting")?; stop()?; @@ -888,12 +889,21 @@ fn with_update_fence_endpoint( // and prevents direct `host serve` attempts during the file replacement. let ownership = endpoint.lock("host.lock")?; wait_update_lock(&ownership, timeout, "Maple host is still shutting down")?; - let result = replace(); - // The new process must acquire these locks normally after the transaction. - // Keep this order explicit: never release admission before replacement. - drop(ownership); - drop(startup); - result + Ok(HostUpdateFence { + _ownership: ownership, + _startup: startup, + }) +} + +#[cfg(all(test, feature = "desktop"))] +fn with_update_fence_endpoint( + endpoint: &ipc::Endpoint, + timeout: Duration, + stop: impl FnOnce() -> Result<(), String>, + replace: impl FnOnce() -> Result, +) -> Result { + let _fence = acquire_update_fence_endpoint(endpoint, timeout, stop)?; + replace() } #[cfg(feature = "desktop")] @@ -916,6 +926,62 @@ mod update_install_tests { use super::*; use std::sync::atomic::AtomicBool; + #[test] + fn committed_restart_fence_survives_the_last_controller_handle() { + let directory = tempfile::tempdir().unwrap(); + let endpoint = ipc::Endpoint::for_test(directory.path().to_path_buf()); + let fence = + acquire_update_fence_endpoint(&endpoint, Duration::from_secs(2), || Ok(())).unwrap(); + let controller = crate::update::Controller::new(true); + controller.set_test_installed_with_fence(directory.path().join("Maple Agent.app"), fence); + let last_handle = controller.clone(); + drop(controller); + drop(last_handle); + // The committed handoff intentionally leaves these two descriptors + // to OS process-exit cleanup, even when GPUI tears down every handle. + for name in ["startup.lock", "host.lock"] { + assert!(matches!( + endpoint.lock(name).unwrap().try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + } + } + + #[test] + fn retained_restart_fence_blocks_queued_and_direct_starts_until_exit() { + let directory = tempfile::tempdir().unwrap(); + let endpoint = ipc::Endpoint::for_test(directory.path().to_path_buf()); + let fence = + acquire_update_fence_endpoint(&endpoint, Duration::from_secs(2), || Ok(())).unwrap(); + let direct = endpoint.lock("host.lock").unwrap(); + let queued = endpoint.lock("startup.lock").unwrap(); + let (started, startup_waiting) = std::sync::mpsc::channel(); + let (admitted, admission) = std::sync::mpsc::channel(); + let client = std::thread::spawn(move || { + started.send(()).unwrap(); + wait_update_lock( + &queued, + Duration::from_secs(2), + "restart fence did not release", + ) + .unwrap(); + admitted.send(()).unwrap(); + }); + startup_waiting + .recv_timeout(Duration::from_secs(1)) + .unwrap(); + assert!(admission.recv_timeout(Duration::from_millis(40)).is_err()); + assert!(matches!( + direct.try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + // Simulate the old desktop exiting after a failed relaunch handoff. + drop(fence); + admission.recv_timeout(Duration::from_secs(1)).unwrap(); + direct.try_lock().unwrap(); + client.join().unwrap(); + } + #[test] fn update_waits_for_drain_and_holds_startup_and_ownership_during_replacement() { let directory = tempfile::tempdir().unwrap(); diff --git a/apps/maple-agent/app/src/update.rs b/apps/maple-agent/app/src/update.rs index d389abb68..f1629335d 100644 --- a/apps/maple-agent/app/src/update.rs +++ b/apps/maple-agent/app/src/update.rs @@ -3,7 +3,7 @@ use std::path::{Path, PathBuf}; use std::sync::{ - Arc, + Arc, OnceLock, atomic::{AtomicBool, Ordering}, }; use std::time::Duration; @@ -28,6 +28,7 @@ pub enum State { Downloading, Ready, Installing, + RestartRequired, Error, } @@ -37,6 +38,7 @@ pub enum UpdateAction { Download, ManualDownload, Restart, + Relaunch, Later, AutomaticDownloads(bool), } @@ -97,7 +99,42 @@ fn disabled_reason(profile: &str, keys: &[&str], hard_off: bool) -> Option<&'sta struct Service { updater: Arc, installed_path: PathBuf, - manual_only: bool, + manual_only: AtomicBool, +} +impl Service { + fn manual_only(&self) -> bool { + self.manual_only.load(Ordering::Acquire) + } +} + +#[derive(Clone, Debug)] +struct InstallationFailure { + message: String, + manual: bool, + recover_host: bool, +} +impl From for InstallationFailure { + fn from(error: maple_agent_updater::installer::InstallError) -> Self { + Self { + manual: error.requires_manual_installation(), + message: error.to_string(), + recover_host: false, + } + } +} +impl From for InstallationFailure { + fn from(message: String) -> Self { + Self { + message, + manual: false, + recover_host: false, + } + } +} +impl From<&str> for InstallationFailure { + fn from(message: &str) -> Self { + message.to_owned().into() + } } #[derive(Default)] struct ReleaseState { @@ -114,6 +151,13 @@ struct Inner { snapshots: watch::Sender, automatic: AtomicBool, cancel: CancellationToken, + installed: OnceLock, +} +struct CommittedInstallation { + path: PathBuf, + // The GPUI helper waits for this PID to exit. Keep old-client admission + // fenced until OS exit closes the files, then the new process can start. + _host_fence: Option>, } /// One controller per desktop process. The operation mutex covers each complete @@ -131,6 +175,7 @@ impl Controller { snapshots, automatic: AtomicBool::new(automatic_downloads), cancel: CancellationToken::new(), + installed: OnceLock::new(), })) } pub fn snapshot(&self) -> Snapshot { @@ -140,12 +185,63 @@ impl Controller { pub(crate) fn set_test_state(&self, state: State) { self.publish(Snapshot::new(state, "Test update state")); } + #[cfg(test)] + pub(crate) fn set_test_installed(&self, path: PathBuf) { + self.installed(path, "0.0.2", None); + } + #[cfg(test)] + pub(crate) fn set_test_installed_with_fence( + &self, + path: PathBuf, + fence: crate::host::HostUpdateFence, + ) { + self.installed(path, "0.0.2", Some(fence)); + } pub fn subscribe(&self) -> watch::Receiver { self.0.snapshots.subscribe() } pub fn shutdown(&self) { self.0.cancel.cancel(); } + /// Once replacement commits, this process can only finish its restart. + /// GPUI's restart API cannot report helper-spawn failure, so retain an + /// accurate retryable state even when the old window remains open. + pub fn restart_path(&self) -> Option { + self.0 + .installed + .get() + .map(|installed| installed.path.clone()) + } + fn installed( + &self, + path: PathBuf, + version: &str, + host_fence: Option, + ) { + // Exactly one committed update can occur in this process. Deliberately + // leave its two lock descriptors open until OS exit, even if GPUI drops + // its final controller Arc during teardown before the PID disappears. + // Pending old-client startup must remain fenced during that interval. + if let Err(rejected) = self.0.installed.set(CommittedInstallation { + path, + _host_fence: host_fence.map(std::mem::ManuallyDrop::new), + }) { + if let Some(fence) = rejected._host_fence { + drop(std::mem::ManuallyDrop::into_inner(fence)); + } + return; + } + self.0.cancel.cancel(); + let mut snapshot = Snapshot::new( + State::RestartRequired, + format!("v{version} is installed. Restart Maple to finish, or quit and reopen it."), + ); + snapshot.version = Some(version.to_owned()); + snapshot.primary = Some(UpdateAction::Relaunch); + snapshot.primary_label = "Restart Maple"; + snapshot.show_banner = true; + self.publish(snapshot); + } /// Best effort on graceful exit. Keep the authenticated downloaded payload /// for restoration, but remove the derived same-volume staging bundle. pub async fn cleanup_on_exit(&self) { @@ -163,6 +259,9 @@ impl Controller { } } fn publish(&self, snapshot: Snapshot) { + if self.restart_path().is_some() && snapshot.state != State::RestartRequired { + return; + } self.0.snapshots.send_if_modified(|current| { if *current == snapshot { false @@ -179,7 +278,19 @@ impl Controller { checking.manual_download_url = previous.manual_download_url; self.publish(checking); } + fn recheck_failed(&self, previous: Snapshot) { + if previous.state == State::Ready { + let mut ready = previous; + ready.label = "The downloaded update is ready. Could not check for updates; restarting will verify it again before installation.".into(); + self.publish(ready); + } else { + self.fail("Could not check for updates. Your current app is still available."); + } + } fn fail(&self, message: impl Into) { + if self.restart_path().is_some() { + return; + } let previous = self.snapshot(); let mut snapshot = Snapshot::new(State::Error, message); snapshot.version = previous.version; @@ -232,6 +343,9 @@ impl Controller { }; tokio::select! { _ = self.0.cancel.cancelled() => return, _ = startup => {} } loop { + if self.restart_path().is_some() { + return; + } let delay = Duration::from_secs(3300 + rand::random::() % 601); tokio::select! { _ = self.0.cancel.cancelled() => return, @@ -241,7 +355,7 @@ impl Controller { } } pub async fn check(&self) { - if self.0.cancel.is_cancelled() { + if self.0.cancel.is_cancelled() || self.restart_path().is_some() { return; } let Ok(_operation) = self.0.operation.try_lock() else { @@ -253,6 +367,7 @@ impl Controller { if self.snapshot().state == State::Disabled { return; } + let previous = self.snapshot(); self.begin_check(); let service = match self.service().await { Ok(service) => service, @@ -279,9 +394,7 @@ impl Controller { self.publish(snapshot); } Ok(CheckOutcome::Available(release)) => self.accept_release(service, *release).await, - Err(_) => { - self.fail("Could not check for updates. Your current app is still available.") - } + Err(_) => self.recheck_failed(previous), } } async fn accept_release(&self, service: &Service, release: VerifiedRelease) { @@ -304,19 +417,19 @@ impl Controller { if let Some(staging) = previous_staging { discard_preparation_with_lock(staging, previous_lock).await; } - if let Some(prepared) = prepared.filter(|_| !service.manual_only) { + if let Some(prepared) = prepared.filter(|_| !service.manual_only()) { match self.stage_prepared(prepared).await { Ok(()) => self.release_snapshot(service, &release, State::Ready).await, - Err(error) => self.fail(error), + Err(error) => self.installation_failed(service, &release, error).await, } - } else if self.0.automatic.load(Ordering::Relaxed) && !service.manual_only { + } else if self.0.automatic.load(Ordering::Relaxed) && !service.manual_only() { self.download_inner(service, release).await; } else { self.release_snapshot(service, &release, State::Available) .await; } } - async fn stage_prepared(&self, prepared: PreparedUpdate) -> Result<(), String> { + async fn stage_prepared(&self, prepared: PreparedUpdate) -> Result<(), InstallationFailure> { // A restored Ready flag is never trusted. Extract/check the signed // package before offering Restart, then check it again at installation. let mut releases = self.0.releases.lock().await; @@ -328,7 +441,7 @@ impl Controller { Some(lock) => lock, None => { maple_agent_updater::installer::acquire_install_lock(&prepared.installed_path) - .map_err(|e| e.to_string())? + .map_err(InstallationFailure::from)? } }; if let Some(previous) = previous { @@ -336,13 +449,13 @@ impl Controller { Ok(()) => return Ok((previous, lock)), Err(error) => { let _ = maple_agent_updater::installer::discard_preparation(&previous); - return Err(error.to_string()); + return Err(error.into()); } } } - let installation = - maple_agent_updater::installer::prepare(&prepared).map_err(|e| e.to_string())?; - Ok((installation, lock)) + let installation = maple_agent_updater::installer::prepare(&prepared) + .map_err(InstallationFailure::from)?; + Ok::<_, InstallationFailure>((installation, lock)) }) .await .map_err(|_| "Could not prepare the update".to_string())??; @@ -351,6 +464,26 @@ impl Controller { releases.install_lock = Some(lock); Ok(()) } + async fn installation_failed( + &self, + service: &Service, + release: &VerifiedRelease, + error: InstallationFailure, + ) { + if error.manual { + service.manual_only.store(true, Ordering::Release); + self.release_snapshot(service, release, State::Available) + .await; + let mut snapshot = self.snapshot(); + snapshot.label = format!( + "{} Download v{} to install manually.", + error.message, release.manifest.version + ); + self.publish(snapshot); + } else { + self.fail(error.message); + } + } async fn release_snapshot(&self, service: &Service, release: &VerifiedRelease, state: State) { let version = release.manifest.version.clone(); let mut snapshot = Snapshot::new( @@ -358,7 +491,7 @@ impl Controller { match state { State::Ready => format!("v{version} is ready to install"), State::Downloading => format!("Downloading v{version}…"), - _ if service.manual_only => { + _ if service.manual_only() => { format!("v{version} is available for manual installation") } _ => format!("v{version} is available"), @@ -370,7 +503,7 @@ impl Controller { snapshot.primary = Some(UpdateAction::Restart); snapshot.primary_label = "Restart to Update"; } else if state == State::Available { - snapshot.primary = Some(if service.manual_only { + snapshot.primary = Some(if service.manual_only() { UpdateAction::ManualDownload } else { UpdateAction::Download @@ -382,7 +515,7 @@ impl Controller { self.publish(snapshot); } pub async fn download(&self) { - if self.0.cancel.is_cancelled() { + if self.0.cancel.is_cancelled() || self.restart_path().is_some() { return; } let Ok(_operation) = self.0.operation.try_lock() else { @@ -401,7 +534,7 @@ impl Controller { } } async fn download_inner(&self, service: &Service, release: VerifiedRelease) { - if service.manual_only { + if service.manual_only() { self.release_snapshot(service, &release, State::Available) .await; return; @@ -413,7 +546,7 @@ impl Controller { self.0.releases.lock().await.prepared = Some(prepared.clone()); match self.stage_prepared(prepared).await { Ok(()) => self.release_snapshot(service, &release, State::Ready).await, - Err(error) => self.fail(error), + Err(error) => self.installation_failed(service, &release, error).await, } } Err(_) => { @@ -435,20 +568,43 @@ impl Controller { } /// Called only after the desktop's explicit task-stop confirmation. Returns /// the verified installed path for GPUI's deliberate restart handoff. - pub async fn install(&self, api_url: String) -> Result, String> { + pub async fn install( + &self, + backend: Arc, + ) -> Result, String> { + if let Some(path) = self.restart_path() { + return Ok(Some(path)); + } if self.0.cancel.is_cancelled() { return Ok(None); } let Ok(_operation) = self.0.operation.try_lock() else { return Ok(None); }; - let result = self.install_inner(api_url).await; + let result = self.install_inner(backend).await; + if let Some(path) = self.restart_path() { + return Ok(Some(path)); + } if let Err(error) = &result { - self.fail(error.clone()); + if let Some(service) = self.0.service.get() { + let release = self.0.releases.lock().await.release.clone(); + if let Some(release) = release { + self.installation_failed(service, &release, error.clone()) + .await; + } else { + self.fail(error.message.clone()); + } + } else { + self.fail(error.message.clone()); + } } - result + result.map_err(|error| error.message) } - async fn install_inner(&self, api_url: String) -> Result, String> { + async fn install_inner( + &self, + backend: Arc, + ) -> Result, InstallationFailure> { + let api_url = backend.api_url().to_owned(); let service = self.service().await?; let prepared = self .0 @@ -472,7 +628,7 @@ impl Controller { let lock = match saved_lock { Some(lock) => lock, None => maple_agent_updater::installer::acquire_install_lock(&installed_path) - .map_err(|e| e.to_string())?, + .map_err(InstallationFailure::from)?, }; let preparation = match saved { Some(preparation) => { @@ -480,15 +636,14 @@ impl Controller { maple_agent_updater::installer::revalidate(&preparation, &staging) { let _ = maple_agent_updater::installer::discard_preparation(&preparation); - return Err(error.to_string()); + return Err(error.into()); } preparation } - None => { - maple_agent_updater::installer::prepare(&staging).map_err(|e| e.to_string())? - } + None => maple_agent_updater::installer::prepare(&staging) + .map_err(InstallationFailure::from)?, }; - Ok::<_, String>((lock, preparation)) + Ok::<_, InstallationFailure>((lock, preparation)) }) .await .map_err(|_| "Could not prepare installation".to_string())??; @@ -531,39 +686,74 @@ impl Controller { } } let recovery_api = api_url.clone(); + let installed_version = prepared.release.manifest.version.clone(); + let controller = self.clone(); let updater = service.updater.clone(); let result = tokio::task::spawn_blocking(move || { let _lock = lock; - let result = crate::host::with_update_fence(&api_url, || { - updater.with_commit_guard(&prepared, || { + // Refresh effective permissions after the asynchronous discovery + // check, before stopping a healthy host or closing admission. + if let Err(error) = maple_agent_updater::installer::preflight_installation(&prepared.installed_path) { + let _ = maple_agent_updater::installer::discard_preparation(&preparation); + return Err(InstallationFailure::from(error)); + } + let manual_failure = std::cell::Cell::new(false); + let mut host_fence = match crate::host::acquire_update_fence(&api_url) { + Ok(fence) => Some(fence), + Err(message) => { + let _ = maple_agent_updater::installer::discard_preparation(&preparation); + return Err(InstallationFailure { message, manual: false, recover_host: true }); + } + }; + let result = updater.with_commit_guard(&prepared, || { // replace performs the final payload/tree/codesign checks // while both process fences and the commit guard are held. - let installed = maple_agent_updater::installer::replace(&preparation, &prepared).map_err(|e| e.to_string())?; + let installed = maple_agent_updater::installer::replace(&preparation, &prepared).map_err(|error| { + manual_failure.set(error.requires_manual_installation()); + error.to_string() + })?; if installed.durability_warning { log::warn!("The update was installed; filesystem synchronization did not complete. Restarting the installed application."); } + // Retire old-process RPC admission and retain host ownership + // until this PID exits, including if the restart helper fails. + backend.retire_after_update(); + controller.installed(installed.installed_path.clone(), &installed_version, host_fence.take()); Ok(installed.installed_path) - }).map_err(|error| error.to_string()) - }); + }).map_err(|error| error.to_string()); + drop(host_fence); if result.is_err() { let _ = maple_agent_updater::installer::discard_preparation(&preparation); } - result + result.map_err(|message| InstallationFailure { message, manual: manual_failure.get(), recover_host: true }) }).await.map_err(|_| "Installation did not complete; retry or reinstall Maple".to_string())?; match result { Ok(path) => Ok(Some(path)), Err(error) => { + if !error.recover_host { + return Err(error); + } // An Err is guaranteed to precede the atomic swap. The host // fence and application lock have both dropped before recovery. if crate::host::recover_after_failed_update(&recovery_api) .await .is_err() { - return Err(format!( - "{error}. Quit and reopen Maple to restore its host." - )); + return Err(InstallationFailure { + message: format!( + "{}. Quit and reopen Maple to restore its host.", + error.message + ), + ..error + }); } - Err(error) + Err(InstallationFailure { + message: format!( + "{}. Quit and reopen Maple before continuing work.", + error.message + ), + ..error + }) } } } @@ -627,7 +817,7 @@ fn build_service() -> Result { Updater::new(config).map_err(|_| "Invalid packaged update configuration")?, ), installed_path, - manual_only, + manual_only: AtomicBool::new(manual_only), }) } @@ -706,19 +896,23 @@ mod tests { ); } #[test] - fn failed_recheck_keeps_the_verified_manual_download_offer() { + fn failed_recheck_retains_ready_restart_and_manual_offer() { let controller = Controller::new(false); let mut downloading = Snapshot::new(State::Ready, "v0.0.2 is ready to install"); downloading.version = Some("0.0.2".into()); downloading.manual_download_url = Some("https://updates.trymaple.ai/agent/stable/releases/0.0.2/Maple.dmg".into()); - controller.publish(downloading); + downloading.primary = Some(UpdateAction::Restart); + downloading.primary_label = "Restart to Update"; + downloading.show_banner = true; + controller.publish(downloading.clone()); controller.begin_check(); assert_eq!(controller.snapshot().state, State::Checking); - controller.fail("Could not check for updates"); + controller.recheck_failed(downloading); let snapshot = controller.snapshot(); - assert_eq!(snapshot.state, State::Error); - assert_eq!(snapshot.primary, Some(UpdateAction::Check)); + assert_eq!(snapshot.state, State::Ready); + assert_eq!(snapshot.primary, Some(UpdateAction::Restart)); + assert!(snapshot.label.contains("verify it again")); assert!(snapshot.manual_download_url.is_some()); assert!(snapshot.show_banner); } @@ -742,7 +936,13 @@ mod tests { controller.download().await; assert!( controller - .install("https://unused.invalid".into()) + .install(Arc::new( + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".into(), + String::new() + ) + .unwrap() + )) .await .unwrap() .is_none() @@ -760,4 +960,84 @@ mod tests { assert_eq!(controller.snapshot().state, State::Disabled); assert!(controller.0.service.get().is_none()); } + #[tokio::test] + async fn committed_update_remains_restartable_without_reinstall_or_network() { + let controller = Controller::new(true); + let path = PathBuf::from("/Applications/Maple Agent.app"); + controller.set_test_installed(path.clone()); + assert_eq!(controller.snapshot().state, State::RestartRequired); + assert_eq!(controller.snapshot().primary, Some(UpdateAction::Relaunch)); + assert!(!controller.snapshot().busy()); + controller.fail("A late pre-restart callback failed"); + controller.check().await; + controller.download().await; + controller.clone().run().await; + let backend = Arc::new( + crate::backend::AgentBackend::for_test("http://127.0.0.1:9".into(), String::new()) + .unwrap(), + ); + assert_eq!( + controller.install(backend).await.unwrap(), + Some(path.clone()) + ); + assert_eq!(controller.restart_path(), Some(path)); + assert_eq!(controller.snapshot().state, State::RestartRequired); + assert!(controller.0.service.get().is_none()); + } + #[tokio::test] + async fn permission_failure_switches_to_signed_manual_download_before_host_stop() { + let controller = Controller::new(true); + let temp = tempfile::tempdir().unwrap(); + let installed = temp.path().join("Maple Agent.app"); + std::fs::create_dir_all(&installed).unwrap(); + let feed: maple_agent_updater::Url = "https://updates.trymaple.ai/agent/stable/latest.json" + .parse() + .unwrap(); + let key = + include_str!("../../crates/maple-agent-updater/tests/fixtures/public-test-key.pub") + .to_owned(); + let release = maple_agent_updater::manifest::verify_envelope( + include_bytes!("../../crates/maple-agent-updater/tests/fixtures/signed-envelope.json"), + &feed, + std::slice::from_ref(&key), + ) + .unwrap(); + let service = Service { + updater: Arc::new( + Updater::new(Config { + feed_url: feed, + public_keys: vec![key], + current_version: "0.0.1".parse().unwrap(), + target: Target::DarwinAarch64, + installed_path: installed.clone(), + state_dir: temp.path().join("updates"), + }) + .unwrap(), + ), + installed_path: installed, + manual_only: AtomicBool::new(false), + }; + let error = InstallationFailure::from(maple_agent_updater::installer::InstallError::Io( + std::io::Error::from(std::io::ErrorKind::PermissionDenied), + )); + assert!( + !error.recover_host, + "known permission failures must precede host shutdown" + ); + controller + .installation_failed(&service, &release, error) + .await; + let snapshot = controller.snapshot(); + assert!(service.manual_only()); + assert_eq!(snapshot.state, State::Available); + assert_eq!(snapshot.primary, Some(UpdateAction::ManualDownload)); + assert!( + snapshot + .manual_download_url + .unwrap() + .ends_with("/Maple-Agent.dmg") + ); + assert!(snapshot.label.contains("install manually")); + assert!(controller.restart_path().is_none()); + } } diff --git a/apps/maple-agent/crates/maple-agent-updater/examples/validate-release-archive.rs b/apps/maple-agent/crates/maple-agent-updater/examples/validate-release-archive.rs new file mode 100644 index 000000000..b4861e985 --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/examples/validate-release-archive.rs @@ -0,0 +1,67 @@ +//! CI compatibility gate using the installer's actual archive parser. +//! It inspects package data and never extracts or executes an application. + +use std::{fs::File, io::Read, path::Path}; + +use maple_agent_updater::installer::validate_archive; +use sha2::{Digest, Sha256}; + +const MAX_ARCHIVE_BYTES: u64 = 4 * 1024 * 1024 * 1024; + +fn digest(path: &Path) -> anyhow::Result<(String, u64)> { + anyhow::ensure!( + std::fs::symlink_metadata(path)?.is_file(), + "archive must be a regular file" + ); + let source = File::open(path)?; + anyhow::ensure!( + source.metadata()?.len() <= MAX_ARCHIVE_BYTES, + "archive is too large" + ); + let mut source = source.take(MAX_ARCHIVE_BYTES + 1); + let mut hash = Sha256::new(); + let mut size = 0_u64; + let mut buffer = [0_u8; 64 * 1024]; + loop { + let length = source.read(&mut buffer)?; + if length == 0 { + break; + } + size += length as u64; + anyhow::ensure!(size <= MAX_ARCHIVE_BYTES, "archive is too large"); + hash.update(&buffer[..length]); + } + anyhow::ensure!(size > 0, "archive is empty"); + Ok((hex::encode(hash.finalize()), size)) +} + +fn main() -> anyhow::Result<()> { + let arguments: Vec<_> = std::env::args_os().skip(1).collect(); + anyhow::ensure!( + arguments.len() == 2, + "usage: validate-release-archive ARCHIVE EXPECTED_SHA256" + ); + let path = Path::new(&arguments[0]); + let expected = arguments[1] + .to_str() + .ok_or_else(|| anyhow::anyhow!("digest must be ASCII"))?; + anyhow::ensure!( + expected.len() == 64 + && expected + .bytes() + .all(|value| value.is_ascii_digit() || (b'a'..=b'f').contains(&value)), + "digest must be lowercase SHA-256" + ); + let before = digest(path)?; + anyhow::ensure!( + before.0 == expected, + "archive does not match the package checksum" + ); + validate_archive(path)?; + anyhow::ensure!(digest(path)? == before, "archive changed during validation"); + println!( + "{}", + serde_json::json!({"archive": path, "sha256": before.0, "size": before.1, "installer_archive_scan": "passed"}) + ); + Ok(()) +} diff --git a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs index ba033cff9..25134160d 100644 --- a/apps/maple-agent/crates/maple-agent-updater/src/installer.rs +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -22,6 +22,8 @@ use crate::{Artifact, PreparedUpdate, Target}; #[cfg(target_os = "macos")] use std::ffi::CString; #[cfg(target_os = "macos")] +use std::os::macos::fs::MetadataExt as _; +#[cfg(target_os = "macos")] use std::os::unix::ffi::OsStrExt; #[cfg(unix)] use std::os::unix::{fs::MetadataExt, fs::OpenOptionsExt, fs::PermissionsExt}; @@ -52,6 +54,18 @@ pub enum InstallError { Io(#[from] io::Error), } +impl InstallError { + /// These failures describe an installation this account cannot replace. + /// Keep them typed until the controller offers a verified manual download. + pub fn requires_manual_installation(&self) -> bool { + match self { + Self::NotifyOnly(_) => true, + Self::Io(error) => permission_error(error), + _ => false, + } + } +} + type Result = std::result::Result; /// The on-disk values are untrusted on restoration. Keep the authenticated @@ -89,6 +103,16 @@ pub struct InstallLock { _file: File, } +impl Drop for InstallLock { + fn drop(&mut self) { + // BSD flock follows the open file description. A transient fork before + // exec may retain that description even though the descriptor has + // CLOEXEC. End this guard's authority explicitly rather than waiting + // for an inherited descriptor's last close. + let _ = FileExt::unlock(&self._file); + } +} + /// Find a supported installation from the real executable, never a saved path. /// AppImage updates remain notify-only until the packaged Linux launch gate. pub fn discover_installation(executable: &Path, _appimage: Option<&Path>) -> Result { @@ -104,11 +128,7 @@ pub fn discover_installation(executable: &Path, _appimage: Option<&Path>) -> Res .ok_or(InstallError::NotifyOnly( "run the installed application bundle", ))?; - validate_destination(app)?; - let _probe = tempfile::Builder::new() - .prefix(".maple-agent-write-probe-") - .tempfile_in(app.parent().unwrap()) - .map_err(destination_error)?; + preflight_installation(app)?; Ok(app.to_owned()) } #[cfg(not(target_os = "macos"))] @@ -120,37 +140,160 @@ pub fn discover_installation(executable: &Path, _appimage: Option<&Path>) -> Res } } +/// Check replacement eligibility before offering Restart and again before +/// stopping the host. This is a preflight, not authority: permissions may still +/// change, and callers must handle a failed atomic replacement safely. +pub fn preflight_installation(installed: &Path) -> Result<()> { + ensure_supported()?; + validate_destination(installed)?; + let parent = installed.parent().unwrap(); + #[cfg(target_os = "macos")] + { + // Darwin's extended access rights check effective ACLs and delete + // authorization without requiring ownership or write access to the + // bundle's contents. A root-owned app can still be replaceable. + const DELETE_OK: libc::c_int = 1 << 12; + const ADD_SUBDIRECTORY_REMOVE_CHILD_SEARCH: libc::c_int = (1 << 13) | (1 << 14) | (1 << 11); + replacement_access(installed, DELETE_OK)?; + replacement_access(parent, ADD_SUBDIRECTORY_REMOVE_CHILD_SEARCH)?; + // SF_NOUNLINK is public in Darwin's sys/stat.h but not in libc. + const SF_NOUNLINK: u32 = 0x0010_0000; + const NO_RENAME_FLAGS: u32 = libc::UF_IMMUTABLE + | libc::UF_APPEND + | libc::SF_IMMUTABLE + | libc::SF_APPEND + | SF_NOUNLINK; + if fs::symlink_metadata(installed)?.st_flags() & NO_RENAME_FLAGS != 0 { + return Err(InstallError::NotifyOnly( + "this application's file flags prevent replacement; install the update manually", + )); + } + } + let _probe = tempfile::Builder::new() + .prefix(".maple-agent-write-probe-") + .tempfile_in(parent) + .map_err(destination_error)?; + // Discover legacy locks owned privately by another account before any + // running tasks are stopped. Opening alone does not contend with Ready. + let _lock = open_install_lock(installed)?; + Ok(()) +} + +#[cfg(target_os = "macos")] +fn replacement_access(path: &Path, rights: libc::c_int) -> Result<()> { + let path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| InstallError::Invalid("invalid installation path"))?; + // Extended access bits are defined in Darwin's sys/unistd.h. AT_EACCESS + // uses the same credentials as the subsequent renamex_np operation. + if unsafe { libc::faccessat(libc::AT_FDCWD, path.as_ptr(), rights, libc::AT_EACCESS) } != 0 { + return Err(destination_error(io::Error::last_os_error())); + } + Ok(()) +} + pub fn acquire_install_lock(installed: &Path) -> Result { validate_destination(installed)?; - let parent = installed - .parent() - .ok_or(InstallError::Invalid("missing installation parent"))?; - let digest = hex::encode(Sha256::digest(installed.as_os_str().as_encoded_bytes())); - let path = parent.join(format!(".maple-agent-install-{digest}.lock")); - let file = open_regular(&path, true, false)?; - validate_private_file(&file)?; + let file = open_install_lock(installed)?; file.try_lock_exclusive().map_err(|e| { if e.kind() == io::ErrorKind::WouldBlock { InstallError::Busy } else { - e.into() + destination_error(e) } })?; + validate_install_lock_path(installed, &file)?; Ok(InstallLock { _file: file }) } +fn install_lock_path(installed: &Path) -> Result { + let parent = installed + .parent() + .ok_or(InstallError::Invalid("missing installation parent"))?; + let digest = hex::encode(Sha256::digest(installed.as_os_str().as_encoded_bytes())); + Ok(parent.join(format!(".maple-agent-install-{digest}.lock"))) +} + +fn open_install_lock(installed: &Path) -> Result { + let path = install_lock_path(installed)?; + let mut create = OpenOptions::new(); + create.read(true).write(true).create_new(true); + #[cfg(unix)] + create + .mode(0o644) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK); + let file = match create.open(&path) { + Ok(file) => { + // The process umask may be private; all accounts using this app + // need to open the same inode. No account needs to write its bytes. + #[cfg(unix)] + file.set_permissions(fs::Permissions::from_mode(0o644))?; + file + } + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { + let mut open = OpenOptions::new(); + open.read(true); + #[cfg(unix)] + open.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK); + open.open(&path).map_err(destination_error)? + } + Err(error) => return Err(destination_error(error)), + }; + #[cfg(unix)] + { + let metadata = file.metadata()?; + if metadata.is_file() + && metadata.nlink() == 1 + && metadata.uid() == unsafe { libc::geteuid() } + && metadata.mode() & 0o7777 == 0o600 + { + // Upgrade the original private lock in place. Replacing its inode + // would allow an already-running updater to escape serialization. + file.set_permissions(fs::Permissions::from_mode(0o644))?; + } + } + validate_install_lock_path(installed, &file)?; + Ok(file) +} + +fn validate_install_lock_path(installed: &Path, file: &File) -> Result<()> { + let metadata = file.metadata()?; + let path_metadata = fs::symlink_metadata(install_lock_path(installed)?)?; + if !metadata.is_file() || !path_metadata.is_file() { + return Err(InstallError::NotifyOnly( + "the shared updater lock is not a regular file; install the update manually", + )); + } + #[cfg(unix)] + if metadata.nlink() != 1 + || metadata.mode() & 0o7022 != 0 + || metadata.dev() != path_metadata.dev() + || metadata.ino() != path_metadata.ino() + { + return Err(InstallError::NotifyOnly( + "the shared updater lock changed or has unsafe permissions; install the update manually", + )); + } + Ok(()) +} + /// The caller retains the application lock for this preparation's entire /// lifetime, including while Ready, until it is installed or discarded. pub fn prepare(update: &PreparedUpdate) -> Result { - ensure_supported()?; + preflight_installation(&update.installed_path)?; prepare_inner(update, Verifier::Native) } pub fn revalidate(preparation: &InstallPreparation, update: &PreparedUpdate) -> Result<()> { - ensure_supported()?; + preflight_installation(&update.installed_path)?; revalidate_inner(preparation, update, Verifier::Native) } +/// Apply the updater's exact archive acceptance rules to a production release +/// payload in CI, without extracting or trusting any archive entry. +pub fn validate_archive(payload: &Path) -> Result<()> { + scan_archive(payload, None).map(|_| ()) +} + /// Remove a withdrawn/cancelled preparation before replacement. Once an install /// receipt exists the directory may contain the prior app, so only a verified /// new startup may clear it through checkpoint cleanup. @@ -488,15 +631,23 @@ fn validate_destination(installed: &Path) -> Result<()> { } fn destination_error(error: io::Error) -> InstallError { - if error.kind() == io::ErrorKind::PermissionDenied - || matches!(error.raw_os_error(), Some(libc::EROFS | libc::EPERM)) - { - InstallError::NotifyOnly("the application directory is not writable") + if permission_error(&error) { + InstallError::NotifyOnly( + "this account cannot replace the application; install the update manually", + ) } else { error.into() } } +fn permission_error(error: &io::Error) -> bool { + error.kind() == io::ErrorKind::PermissionDenied + || matches!( + error.raw_os_error(), + Some(libc::EACCES | libc::EROFS | libc::EPERM) + ) +} + fn validate_staging(installed: &Path, root: &Path) -> Result<()> { if root.parent() != installed.parent() || !root.file_name().and_then(|n| n.to_str()).is_some_and(|n| { @@ -545,24 +696,6 @@ fn open_regular(path: &Path, create: bool, exclusive: bool) -> Result { Ok(file) } -fn validate_private_file(file: &File) -> Result<()> { - #[cfg(not(unix))] - let _ = file; - #[cfg(unix)] - { - let metadata = file.metadata()?; - if metadata.nlink() != 1 - || metadata.uid() != unsafe { libc::geteuid() } - || metadata.mode() & 0o077 != 0 - { - return Err(InstallError::Invalid( - "private file permissions or ownership changed", - )); - } - } - Ok(()) -} - fn verify_payload(path: &Path, artifact: &Artifact) -> Result<()> { let mut file = open_regular(path, false, false)?; if file.metadata()?.len() != artifact.size { @@ -1594,6 +1727,175 @@ mod tests { assert!(acquire_install_lock(&installed).is_ok()); } + #[test] + fn shared_read_only_lock_excludes_installers_without_owner_only_permissions() { + let root = tempfile::tempdir().unwrap(); + let installed = root.path().join(APP_NAME); + fs::create_dir(&installed).unwrap(); + let installed = installed.canonicalize().unwrap(); + let lock = acquire_install_lock(&installed).unwrap(); + let path = install_lock_path(&installed).unwrap(); + assert_eq!(fs::metadata(&path).unwrap().mode() & 0o777, 0o644); + drop(lock); + + // A different account opens this same shared inode read-only. flock + // still grants exclusive locks on that descriptor, on macOS and Linux. + let read_only = OpenOptions::new().read(true).open(&path).unwrap(); + FileExt::try_lock_exclusive(&read_only).unwrap(); + assert!(matches!( + acquire_install_lock(&installed), + Err(InstallError::Busy) + )); + FileExt::unlock(&read_only).unwrap(); + drop(read_only); + assert!(acquire_install_lock(&installed).is_ok()); + } + + #[test] + fn dropping_install_guard_unlocks_while_an_inherited_description_remains_open() { + let root = tempfile::tempdir().unwrap(); + let installed = root.path().join(APP_NAME); + fs::create_dir(&installed).unwrap(); + let installed = installed.canonicalize().unwrap(); + let first = acquire_install_lock(&installed).unwrap(); + // dup and fork share the same open file description. Retaining this + // duplicate models a child between fork and exec without forking from + // the multithreaded test harness. + let inherited = first._file.try_clone().unwrap(); + assert!(matches!( + acquire_install_lock(&installed), + Err(InstallError::Busy) + )); + drop(first); + let second = acquire_install_lock(&installed) + .expect("the guard releases ownership even before an inherited descriptor closes"); + drop(inherited); + assert!(matches!( + acquire_install_lock(&installed), + Err(InstallError::Busy) + )); + drop(second); + assert!(acquire_install_lock(&installed).is_ok()); + } + + #[test] + fn migrates_owned_private_install_lock_without_replacing_or_unlocking_it() { + let root = tempfile::tempdir().unwrap(); + let installed = root.path().join(APP_NAME); + fs::create_dir(&installed).unwrap(); + let installed = installed.canonicalize().unwrap(); + let path = install_lock_path(&installed).unwrap(); + let original = open_regular(&path, true, true).unwrap(); + original + .set_permissions(fs::Permissions::from_mode(0o600)) + .unwrap(); + FileExt::try_lock_exclusive(&original).unwrap(); + let inode = original.metadata().unwrap().ino(); + assert!(matches!( + acquire_install_lock(&installed), + Err(InstallError::Busy) + )); + let metadata = fs::metadata(&path).unwrap(); + assert_eq!(metadata.ino(), inode); + assert_eq!(metadata.mode() & 0o777, 0o644); + // This fixture is a raw File, not an InstallLock guard. Explicitly + // release it before close, including when concurrent native commands + // temporarily inherited its open description before their exec. + FileExt::unlock(&original).unwrap(); + drop(original); + acquire_install_lock(&installed).expect("the released migrated lock can be reacquired"); + } + + #[test] + fn shared_install_lock_rejects_writable_links_and_replaced_inodes() { + let root = tempfile::tempdir().unwrap(); + let installed = root.path().join(APP_NAME); + fs::create_dir(&installed).unwrap(); + let installed = installed.canonicalize().unwrap(); + let path = install_lock_path(&installed).unwrap(); + let file = open_install_lock(&installed).unwrap(); + file.set_permissions(fs::Permissions::from_mode(0o666)) + .unwrap(); + assert!(matches!( + acquire_install_lock(&installed), + Err(InstallError::NotifyOnly(_)) + )); + file.set_permissions(fs::Permissions::from_mode(0o644)) + .unwrap(); + let alias = root.path().join("lock-alias"); + fs::hard_link(&path, &alias).unwrap(); + assert!(validate_install_lock_path(&installed, &file).is_err()); + fs::remove_file(alias).unwrap(); + fs::remove_file(&path).unwrap(); + let _replacement = open_install_lock(&installed).unwrap(); + assert!(validate_install_lock_path(&installed, &file).is_err()); + } + + #[test] + fn permission_failures_require_manual_installation_without_hiding_busy_or_integrity_errors() { + for code in [libc::EACCES, libc::EPERM, libc::EROFS] { + assert!( + InstallError::Io(io::Error::from_raw_os_error(code)).requires_manual_installation() + ); + } + assert!(InstallError::NotifyOnly("manual").requires_manual_installation()); + assert!(!InstallError::Busy.requires_manual_installation()); + assert!(!InstallError::CodeSignature.requires_manual_installation()); + assert!(!InstallError::Invalid("changed payload").requires_manual_installation()); + assert!( + !InstallError::Io(io::Error::from_raw_os_error(libc::ENOSPC)) + .requires_manual_installation() + ); + } + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + #[test] + fn native_replacement_preflight_accepts_a_supported_bundle_and_detects_delete_acl() { + let root = tempfile::tempdir().unwrap(); + let installed = root.path().join(APP_NAME); + fs::create_dir(&installed).unwrap(); + let installed = installed.canonicalize().unwrap(); + preflight_installation(&installed).unwrap(); + let status = std::process::Command::new("/bin/chmod") + .args(["+a", "everyone deny delete"]) + .arg(&installed) + .status() + .unwrap(); + assert!(status.success()); + let result = preflight_installation(&installed); + let cleared = std::process::Command::new("/bin/chmod") + .arg("-N") + .arg(&installed) + .status() + .unwrap(); + assert!(cleared.success(), "restore the fixture's deletability"); + assert!(matches!(result, Err(InstallError::NotifyOnly(_)))); + } + + #[cfg(all(target_os = "macos", target_arch = "aarch64"))] + #[test] + fn native_replacement_preflight_rejects_immutable_app_flags() { + let root = tempfile::tempdir().unwrap(); + let installed = root.path().join(APP_NAME); + fs::create_dir(&installed).unwrap(); + let installed = installed.canonicalize().unwrap(); + preflight_installation(&installed).unwrap(); + let status = std::process::Command::new("/usr/bin/chflags") + .arg("uchg") + .arg(&installed) + .status() + .unwrap(); + assert!(status.success()); + let result = preflight_installation(&installed); + let cleared = std::process::Command::new("/usr/bin/chflags") + .arg("nouchg") + .arg(&installed) + .status() + .unwrap(); + assert!(cleared.success(), "restore the fixture's deletability"); + assert!(matches!(result, Err(InstallError::NotifyOnly(_)))); + } + #[test] fn deferred_preparation_is_protected_then_reclaimed_after_process_lock_release() { let root = tempfile::tempdir().unwrap(); diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 136690730..a8bd70241 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -38,9 +38,15 @@ binary and checks its embedded profile and source commit against the run's checkout before the signing step receives Apple credentials. It reads a dedicated Mach-O JSON section without executing artifact code, including before signing. No app binary runs anywhere on the signing runner. Packaging does not restore a -Cargo cache or compile Rust; this keeps compilation's disk use -separate from bundling and notarization. PR previews use the same prebuilt -verification on runners without signing credentials. Runtime metadata, duplicate +Cargo cache or compile the application. For Prod, the packaging runner first +compiles a small updater-only scanner from its trusted checkout, before the step +receiving Apple credentials. After packaging, that tool reads the actual +`.app.tar.gz` through the installer's archive scanner, without extracting or +executing the app, and checks its SHA-256 against the package checksums before +final artifact upload or attestation. The same gate covers unsigned Prod PR +packages. Dev uses a different bundle root and has no production updater. +PR previews use the same prebuilt verification on runners without signing +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. @@ -71,6 +77,13 @@ coexist with Maple Research. Linux packages contain an AppImage; make it executable and launch it. Each artifact also contains public build information, a package manifest, and checksums. +Native BSDtar creation explicitly disables xattrs, file flags, ACLs and macOS +metadata, while preserving signed file bytes, public modes and symlink targets. +`COPYFILE_DISABLE` alone does not suppress all PAX host metadata. Normal native +format support remains enabled for required long paths; unsupported metadata is +rejected by the real installer scanner. Existing extracted-archive codesign, +notarization, Gatekeeper and DMG comparison checks remain required. + Every final master package file, including its public metadata and checksums, receives GitHub build provenance. The trusted macOS signing job mints provenance after packaging. Linux compilation uses a read-only token; a separate trusted @@ -112,11 +125,19 @@ nix develop --no-update-lock-file . -c ./scripts/package-release.sh dev --unsign nix develop --no-update-lock-file . -c ./scripts/verify-release.sh dev dist/dev --unsigned ``` +For a produced Prod updater archive, compile the trusted data-only scanner and +validate the exact package directory (add `--unsigned` for PR packages): + +```sh +nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive +nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh dist/prod --unsigned +``` + Use `prod` for the production service profile. Official macOS signing is run only by the trusted CI job using the separate `agent-desktop-signing` GitHub -environment. Cargo and native compilation run before that job step receives -Apple credentials, on a separate runner. Linux packaging needs no Apple -credentials. +environment. Application compilation runs on a separate runner; the trusted +updater scanner compiles before the job step receiving Apple credentials. Linux +packaging needs no Apple credentials. ## Distribution and update boundaries diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index f6a56daaa..4a64bfdb5 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -30,8 +30,21 @@ the new installed version starts successfully and validates its receipt. This is crash recovery retention, not automatic rollback or permission to install an older signed version. -Read-only locations, disk images and translocated bundles offer a manual -download. Linux currently offers manual installation: the AppImage self-launch +After replacement commits, the old process retires its backend and enters +**Restart required** before asking the platform to relaunch. If the restart +helper cannot start, **Restart Maple** retries only that committed application +path; Quit remains available so the user can reopen the installed update. +Checks, downloads, a second installation and new backend work cannot resume in +the old process. A relaunch failure never triggers an automatic downgrade. + +Read-only locations, disk images, translocated bundles and accounts lacking +replacement permission offer a manual download before active tasks stop. The +installation lock uses one shared inode for the canonical application, readable +by other Mac accounts; separate profile locks do not replace that exclusion. +Permissions are rechecked before host shutdown and replacement can still fail +safely if the filesystem changes afterward. + +Linux currently offers manual installation: the AppImage self-launch repair is a prerequisite, and does not by itself prove safe replacement and relaunch. Enabling Linux automatic installation requires the packaged gates below. Unsupported architectures do not install another architecture's payload. @@ -123,6 +136,11 @@ Workflow and configuration changes also require the root pinned `nix flake check --no-update-lock-file`. Focused core and installer tests cover signature, schema, anti-rollback, interrupted downloads, saved-state tampering, archive/path attacks, staging integrity, replacement receipts and lock exclusion. +Prod macOS packaging also runs the real installer archive scanner against each +produced unsigned PR or signed master `.app.tar.gz`, binding the scanned bytes to +the package checksum before final upload/attestation. BSDtar host metadata is +explicitly omitted during creation; signed file contents and existing native +signature/notarization checks remain intact. They do not prove Developer ID distribution, deployed service behavior, or a successful real package update. diff --git a/apps/maple-agent/scripts/macos-release-app.sh b/apps/maple-agent/scripts/macos-release-app.sh index 9610effc8..207dd056b 100755 --- a/apps/maple-agent/scripts/macos-release-app.sh +++ b/apps/maple-agent/scripts/macos-release-app.sh @@ -21,6 +21,15 @@ macos_embed_dylibs() { python3 "$component/scripts/macos-release-dylibs.py" "$binary" "$app" } +macos_archive_app() { + local staging="$1" app_name="$2" archive="$3" + # COPYFILE_DISABLE alone still permits BSDtar PAX xattrs and file flags. + # Keep signed file bytes, public modes and links, but omit host metadata the + # installer's fail-closed archive scanner intentionally rejects. + COPYFILE_DISABLE=1 macos_native tar --no-xattrs --no-fflags --no-acls --no-mac-metadata \ + -czf "$archive" -C "$staging" "$app_name.app" +} + macos_release_cleanup() { local status=$? trap - EXIT INT TERM HUP @@ -242,7 +251,7 @@ print(-(-total//(1024*1024)))' "$image_root")" macos_native xcrun stapler validate "$output_dir/$basename.dmg" macos_native spctl --assess --type open --context context:primary-signature --verbose=2 "$output_dir/$basename.dmg" fi - COPYFILE_DISABLE=1 macos_native tar -czf "$output_dir/$basename.app.tar.gz" -C "$macos_release_staging" "$app_name.app" + macos_archive_app "$macos_release_staging" "$app_name" "$output_dir/$basename.app.tar.gz" chmod 0644 "$output_dir/$basename.dmg" "$output_dir/$basename.app.tar.gz" } diff --git a/apps/maple-agent/scripts/test_release_packaging.py b/apps/maple-agent/scripts/test_release_packaging.py index 423da619e..7c21d79b7 100644 --- a/apps/maple-agent/scripts/test_release_packaging.py +++ b/apps/maple-agent/scripts/test_release_packaging.py @@ -25,6 +25,52 @@ spec.loader.exec_module(release_info) +class NativeArchiveMetadataTests(unittest.TestCase): + @unittest.skipUnless(sys.platform == "darwin", "requires native macOS BSDtar metadata") + def test_native_release_tar_omits_host_metadata_and_preserves_payload(self): + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + app = root / "Maple Agent.app" + executable = app / "Contents/MacOS/maple-agent" + executable.parent.mkdir(parents=True) + executable.write_bytes(b"public executable bytes\n") + executable.chmod(0o755) + ticket = app / "Contents/CodeResources" + ticket.write_bytes(b"stapled ticket file bytes") + ticket.chmod(0o644) + signature = app / "Contents/_CodeSignature/CodeResources" + signature.parent.mkdir() + signature.write_bytes(b"signed resource manifest bytes") + signature.chmod(0o644) + link = app / "Contents/current" + link.symlink_to("MacOS/maple-agent") + subprocess.run(["/usr/bin/xattr", "-w", "com.maple.archive-fixture", "host-only metadata", str(executable)], check=True) + subprocess.run(["/bin/chmod", "+a", "everyone allow read", str(executable)], check=True) + subprocess.run(["/usr/bin/chflags", "hidden", str(executable)], check=True) + self.assertIn("com.maple.archive-fixture", subprocess.check_output(["/usr/bin/xattr", str(executable)], text=True).splitlines()) + archive = root / "actual.app.tar.gz" + result = subprocess.run([ + "/bin/bash", "-c", 'source "$1"; macos_archive_app "$2" "$3" "$4"', + "native-release-archive-test", str(SCRIPTS / "macos-release-app.sh"), + str(root), "Maple Agent", str(archive), + ], check=True, capture_output=True, text=True) + self.assertEqual(result.returncode, 0) + with tarfile.open(archive) as contents: + for member in contents: + self.assertFalse(any("xattr" in key.lower() or "acl" in key.lower() or "fflags" in key.lower() for key in member.pax_headers), member.pax_headers) + self.assertFalse(Path(member.name).name.startswith("._"), member.name) + output = root / "roundtrip" + output.mkdir() + subprocess.run(["/usr/bin/tar", "-xf", str(archive), "-C", str(output)], check=True) + extracted = output / app.name + for original in (executable, ticket, signature): + copy = extracted / original.relative_to(app) + self.assertEqual(copy.read_bytes(), original.read_bytes()) + self.assertEqual(copy.stat().st_mode & 0o777, original.stat().st_mode & 0o777) + self.assertEqual(os.readlink(extracted / link.relative_to(app)), "MacOS/maple-agent") + self.assertNotIn("com.maple.archive-fixture", subprocess.check_output(["/usr/bin/xattr", str(extracted / executable.relative_to(app))], text=True).splitlines()) + + class NativeGatekeeperTests(unittest.TestCase): @unittest.skipUnless(sys.platform == "darwin", "requires native macOS Gatekeeper") def test_native_gatekeeper_resolves_without_path_lookup(self): @@ -496,7 +542,15 @@ def run_packager(self, unsigned=False, failure="", signal=False, search_list=Non fi touch "${{@:$#}}" ;; /bin/df) echo 'Filesystem Size Used Avail Capacity Mounted on' ;; - tar) command tar "$@" ;; + tar) + # The mocked native boundary may run on Linux; actual BSDtar + # metadata behavior has a separate native macOS regression. + local args=() argument + for argument in "$@"; do + case "$argument" in --no-xattrs|--no-fflags|--no-acls|--no-mac-metadata) ;; + *) args+=("$argument") ;; esac + done + command tar "${{args[@]}}" ;; *) echo 'unexpected native tool' >&2; return 1 ;; esac }} @@ -545,6 +599,8 @@ def test_inside_out_signing_notarization_and_ephemeral_keychain_cleanup(self): result, calls = self.run_packager() self.assertEqual(result.returncode, 0, result.stderr) self.assert_secrets_not_returned(result) + archive = next(call for call in calls if call[0] == "tar") + self.assertEqual(archive[1:5], ["--no-xattrs", "--no-fflags", "--no-acls", "--no-mac-metadata"]) signs = [call for call in calls if call[0] == "codesign" and "--force" in call] self.assertTrue(signs[0][-1].endswith("libswiftFixture.dylib")) self.assertTrue(signs[1][-1].endswith(".app")) diff --git a/apps/maple-agent/scripts/verify-updater-archive.sh b/apps/maple-agent/scripts/verify-updater-archive.sh new file mode 100755 index 000000000..a16139b42 --- /dev/null +++ b/apps/maple-agent/scripts/verify-updater-archive.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Read actual produced Prod package data with the trusted installer scanner. +set -euo pipefail +if [[ $# -lt 1 || $# -gt 2 || ( $# == 2 && "$2" != --unsigned ) ]]; then + echo "usage: verify-updater-archive.sh ARTIFACT_DIRECTORY [--unsigned]" >&2 + exit 2 +fi +component="$(cd "$(dirname "$0")/.." && pwd)" +directory="$(cd "$1" && pwd)" +unsigned=() +[[ $# == 1 ]] || unsigned=(--unsigned) +scanner="$component/target/debug/examples/validate-release-archive" +[[ -x "$scanner" && ! -L "$scanner" ]] || { echo "build the trusted updater archive scanner first" >&2; exit 1; } +python3 "$component/scripts/release-info.py" verify prod "$directory" \ + --source-sha "$(git -C "$component" rev-parse HEAD)" "${unsigned[@]}" >/dev/null +archives=("$directory"/*.app.tar.gz) +[[ ${#archives[@]} == 1 && -f "${archives[0]}" && ! -L "${archives[0]}" ]] || { echo "expected exactly one regular Prod app archive" >&2; exit 1; } +archive="${archives[0]}" +expected="$(python3 - "$directory/SHA256SUMS" "$(basename "$archive")" <<'PY' +import re,sys +lines=open(sys.argv[1], encoding="ascii").read().splitlines() +matches=[line.split(" ",1)[0] for line in lines if line.endswith(" "+sys.argv[2])] +if len(matches) != 1 or not re.fullmatch(r"[0-9a-f]{64}", matches[0]): + raise SystemExit("archive must have one package checksum") +print(matches[0]) +PY +)" +"$scanner" "$archive" "$expected" diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 899734649..91bd154fa 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -358,8 +358,12 @@ def test_macos_packaging_uses_verified_prebuilt_binaries_on_fresh_runners(self): steps = job["steps"] self.assertFalse(any("rust-cache@" in step.get("uses", "") for step in steps)) self.assertFalse(any("build-release.sh" in step.get("run", "") for step in steps)) - for step in steps: - self.assertNotRegex(step.get("run", ""), r"\bcargo\b") + scanner_builds = [i for i, step in enumerate(steps) if "cargo" in step.get("run", "")] + self.assertEqual(len(scanner_builds), 1) + scanner_build = scanner_builds[0] + self.assertEqual(steps[scanner_build]["run"], "nix develop --no-update-lock-file . -c cargo build --locked -p maple-agent-updater --example validate-release-archive") + self.assertEqual(steps[scanner_build]["if"], "${{ matrix.variant == 'prod' }}") + self.assertNotIn("env", steps[scanner_build], "scanner compilation must receive no signing credentials") download = next(i for i, step in enumerate(steps) if step.get("uses", "").startswith("actions/download-artifact@")) self.assertEqual(steps[download]["with"], { @@ -369,6 +373,17 @@ def test_macos_packaging_uses_verified_prebuilt_binaries_on_fresh_runners(self): if "./scripts/verify-prebuilt-release.sh" in step.get("run", "")) package = next(i for i, step in enumerate(steps) if "./scripts/package-release.sh" in step.get("run", "")) + scanner = next(i for i, step in enumerate(steps) + if "./scripts/verify-updater-archive.sh" in step.get("run", "")) + self.assertLess(scanner_build, package) + self.assertGreater(scanner, package) + self.assertEqual(steps[scanner]["if"], "${{ matrix.variant == 'prod' }}") + self.assertNotIn("env", steps[scanner], "archive data inspection must receive no signing credentials") + self.assertIn('"dist/$MAPLE_RELEASE_PROFILE"', steps[scanner]["run"]) + self.assertEqual("--unsigned" in steps[scanner]["run"], name == "macos-unsigned") + for index, step in enumerate(steps): + if step.get("uses", "").startswith(("actions/upload-artifact@", "actions/attest@")): + self.assertLess(scanner, index, "actual archive must pass the installer parser before publication") self.assertGreater(verify, download) self.assertGreater(package, verify) self.assertNotIn("env", steps[verify]) From f13610ba089b4b392f80af5eb908bc5e7653dc8a Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 07:14:34 +0000 Subject: [PATCH 07/12] test(agent): scope macOS permission fixture to its platform The signed fixture has only a macOS download. Linux correctly selects no URL, so its CI exposed the test-only mismatch. Limit this installer-permission regression to macOS instead of changing production platform selection. The production code is unchanged. Pinned formatting and the focused macOS regression passed after the full Agent and packaging validation of the implementation. --- apps/maple-agent/app/src/update.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/maple-agent/app/src/update.rs b/apps/maple-agent/app/src/update.rs index f1629335d..365c21513 100644 --- a/apps/maple-agent/app/src/update.rs +++ b/apps/maple-agent/app/src/update.rs @@ -984,6 +984,9 @@ mod tests { assert_eq!(controller.snapshot().state, State::RestartRequired); assert!(controller.0.service.get().is_none()); } + // This exercises macOS installation permissions with the macOS-only signed + // fixture. Linux discovery requires its own artifact/download inventory. + #[cfg(target_os = "macos")] #[tokio::test] async fn permission_failure_switches_to_signed_manual_download_before_host_stop() { let controller = Controller::new(true); From 8b5181d41ac24897f8e40a5e93c5624ab87881ce Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:15 +0000 Subject: [PATCH 08/12] fix(agent-updates): reuse existing publishing credentials --- .github/workflows/agent-updates-publish.yml | 50 +++++++++++++---- docs/app-signing-environments.md | 8 +++ scripts/ci/test_agent_updates_workflows.py | 59 ++++++++++++++++++--- scripts/ci/test_signing_workflows.py | 18 ++++--- services/agent-updates/README.md | 34 +++++++----- services/agent-updates/publisher/README.md | 8 ++- 6 files changed, 140 insertions(+), 37 deletions(-) diff --git a/.github/workflows/agent-updates-publish.yml b/.github/workflows/agent-updates-publish.yml index 7d3e9a30a..2f49a4be5 100644 --- a/.github/workflows/agent-updates-publish.yml +++ b/.github/workflows/agent-updates-publish.yml @@ -95,7 +95,7 @@ jobs: reserve: needs: verify if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' }} - environment: agent-updates-production + environment: updates-production runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -114,10 +114,21 @@ jobs: with: name: agent-update-prepared-${{ github.run_id }} path: bundle + - name: Realize pinned publisher tools before retrieving credentials + run: nix develop --no-update-lock-file .#ci -c true + - name: Resolve Agent bucket credentials from Bitwarden Secrets Manager + id: r2-credentials + uses: bitwarden/sm-action@1238aae8fc64b212641190a9227c8a734ab1a793 # v3.0.1 + with: + access_token: ${{ secrets.AGENT_UPDATES_BWS_ACCESS_TOKEN }} + secrets: | + ${{ vars.AGENT_R2_ACCESS_KEY_ID_SECRET_ID }} > AWS_ACCESS_KEY_ID + ${{ vars.AGENT_R2_SECRET_ACCESS_KEY_SECRET_ID }} > AWS_SECRET_ACCESS_KEY + set_env: false - name: Reserve immutable exact manifest and reuse prior signed envelope env: - AWS_ACCESS_KEY_ID: ${{ secrets.AGENT_R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AGENT_R2_SECRET_ACCESS_KEY }} + AWS_ACCESS_KEY_ID: ${{ steps.r2-credentials.outputs.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ steps.r2-credentials.outputs.AWS_SECRET_ACCESS_KEY }} R2_ACCOUNT: ${{ vars.AGENT_R2_ACCOUNT_ID }} R2_BUCKET: ${{ vars.AGENT_R2_BUCKET }} run: | @@ -138,7 +149,7 @@ jobs: sign: needs: reserve if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' }} - environment: agent-update-signing + environment: updates-production runs-on: ubuntu-latest timeout-minutes: 15 steps: @@ -157,9 +168,19 @@ jobs: with: name: agent-update-reserved-${{ github.run_id }} path: bundle + - name: Realize pinned publisher tools before retrieving credentials + run: nix develop --no-update-lock-file .#ci -c true + - name: Resolve Agent update key from Bitwarden Secrets Manager + id: signing-key + uses: bitwarden/sm-action@1238aae8fc64b212641190a9227c8a734ab1a793 # v3.0.1 + with: + access_token: ${{ secrets.AGENT_UPDATES_BWS_ACCESS_TOKEN }} + secrets: | + ${{ vars.AGENT_UPDATE_SIGNING_KEY_ID }} > AGENT_UPDATE_SIGNING_KEY + set_env: false - name: Sign exact reserved manifest with Agent-only key env: - AGENT_UPDATE_SIGNING_KEY: ${{ secrets.AGENT_UPDATE_SIGNING_KEY }} + AGENT_UPDATE_SIGNING_KEY: ${{ steps.signing-key.outputs.AGENT_UPDATE_SIGNING_KEY }} run: | set -euo pipefail umask 077 @@ -181,9 +202,7 @@ jobs: publish: needs: sign if: ${{ github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && github.event_name == 'workflow_dispatch' }} - environment: - name: agent-updates-production - url: https://updates.trymaple.ai/agent/stable/latest.json + environment: updates-production runs-on: ubuntu-latest timeout-minutes: 45 steps: @@ -202,10 +221,21 @@ jobs: with: name: agent-update-signed-${{ github.run_id }} path: bundle + - name: Realize pinned publisher tools before retrieving credentials + run: nix develop --no-update-lock-file .#ci -c true + - name: Resolve Agent bucket credentials from Bitwarden Secrets Manager + id: r2-credentials + uses: bitwarden/sm-action@1238aae8fc64b212641190a9227c8a734ab1a793 # v3.0.1 + with: + access_token: ${{ secrets.AGENT_UPDATES_BWS_ACCESS_TOKEN }} + secrets: | + ${{ vars.AGENT_R2_ACCESS_KEY_ID_SECRET_ID }} > AWS_ACCESS_KEY_ID + ${{ vars.AGENT_R2_SECRET_ACCESS_KEY_SECRET_ID }} > AWS_SECRET_ACCESS_KEY + set_env: false - name: Upload immutably, conditionally promote, and verify public delivery env: - AWS_ACCESS_KEY_ID: ${{ secrets.AGENT_R2_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AGENT_R2_SECRET_ACCESS_KEY }} + AWS_ACCESS_KEY_ID: ${{ steps.r2-credentials.outputs.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ steps.r2-credentials.outputs.AWS_SECRET_ACCESS_KEY }} R2_ACCOUNT: ${{ vars.AGENT_R2_ACCOUNT_ID }} R2_BUCKET: ${{ vars.AGENT_R2_BUCKET }} run: | diff --git a/docs/app-signing-environments.md b/docs/app-signing-environments.md index 53f8b14e7..ea445d5866 100644 --- a/docs/app-signing-environments.md +++ b/docs/app-signing-environments.md @@ -63,6 +63,14 @@ merged, remove this environment's required reviewer while retaining its exact setting, not a new approval for each release. See the [updater rollout checks](../services/updates/README.md). +The independent Agent publisher reuses `updates-production` for manual trusted-master +reservation, signing and publication, with Agent-specific BWS item-ID variables +and an existing BWS access credential stored as `AGENT_UPDATES_BWS_ACCESS_TOKEN`. +The pinned action passes outputs only to the consuming step, without exporting +them to subsequent steps. This shared access does not isolate the environment's +secret context or narrow the BWS credential's grants. Agent serialization, keys +and bucket remain independent; Research's workflow and configuration are unchanged. + `apple-signing` remains automatic for now because Maple Dev and production iOS share its credentials. Gating this environment would also interrupt the automatic Maple Dev TestFlight lane, potentially at both its build and upload diff --git a/scripts/ci/test_agent_updates_workflows.py b/scripts/ci/test_agent_updates_workflows.py index 2c0f92c6c..c01fb78b5 100644 --- a/scripts/ci/test_agent_updates_workflows.py +++ b/scripts/ci/test_agent_updates_workflows.py @@ -49,23 +49,68 @@ def test_manual_master_only_and_separate_authority(self): environment = job.get("environment") if isinstance(environment, dict): environment = environment["name"] - if name == "sign": - self.assertEqual(environment, "agent-update-signing") - elif name in {"reserve", "publish"}: - self.assertEqual(environment, "agent-updates-production") + if name in {"reserve", "sign", "publish"}: + self.assertEqual(environment, "updates-production") self.assertNotIn("permissions", job) for step in job["steps"]: self.assertNotIn("continue-on-error", step) for value in step.get("env", {}).values(): if "secrets." in str(value): secret = re.search(r"secrets\.([A-Z0-9_]+)", value)[1] - self.assertIn(secret, {"AGENT_UPDATE_SIGNING_KEY"} if name == "sign" else { - "AGENT_R2_ACCESS_KEY_ID", "AGENT_R2_SECRET_ACCESS_KEY", - } if name in {"reserve", "publish"} else set()) + self.fail(f"credential must be passed from a step output: {secret}") if step.get("uses", "").startswith("actions/checkout@"): self.assertEqual(step["with"]["ref"], "${{ github.sha }}") self.assertIs(step["with"]["persist-credentials"], False) + def test_bws_reuses_existing_access_without_exporting_credentials_to_later_steps(self): + jobs = workflow("agent-updates-publish.yml")["jobs"] + bws_action = workflow("opensecret-eif-release.yml")["jobs"]["sign"]["steps"] + bws_action = next(step["uses"] for step in bws_action + if step.get("uses", "").startswith("bitwarden/sm-action@")) + self.assertEqual(bws_action, "bitwarden/sm-action@1238aae8fc64b212641190a9227c8a734ab1a793") + for name in ("reserve", "sign", "publish"): + with self.subTest(job=name): + job = jobs[name] + self.assertNotIn("env", job) + steps = job["steps"] + fetches = [i for i, step in enumerate(steps) + if step.get("uses", "").startswith("bitwarden/sm-action@")] + self.assertEqual(len(fetches), 1) + index = fetches[0] + fetch = steps[index] + self.assertEqual(fetch["uses"], bws_action) + self.assertEqual(fetch["with"]["access_token"], "${{ secrets.AGENT_UPDATES_BWS_ACCESS_TOKEN }}") + self.assertIs(fetch["with"]["set_env"], False) + realization = next(i for i, step in enumerate(steps) + if step.get("run") == "nix develop --no-update-lock-file .#ci -c true") + download = next(i for i, step in enumerate(steps) + if step.get("uses", "").startswith("actions/download-artifact@")) + self.assertLess(download, realization) + self.assertLess(realization, index) + for step in steps: + references = re.findall(r"secrets\.([A-Z0-9_]+)", json.dumps(step)) + self.assertEqual(references, ["AGENT_UPDATES_BWS_ACCESS_TOKEN"] if step is fetch else []) + if name == "sign": + self.assertEqual(fetch["with"]["secrets"].splitlines(), [ + "${{ vars.AGENT_UPDATE_SIGNING_KEY_ID }} > AGENT_UPDATE_SIGNING_KEY", + ]) + expected = {"AGENT_UPDATE_SIGNING_KEY": "${{ steps.signing-key.outputs.AGENT_UPDATE_SIGNING_KEY }}"} + else: + self.assertEqual(fetch["with"]["secrets"].splitlines(), [ + "${{ vars.AGENT_R2_ACCESS_KEY_ID_SECRET_ID }} > AWS_ACCESS_KEY_ID", + "${{ vars.AGENT_R2_SECRET_ACCESS_KEY_SECRET_ID }} > AWS_SECRET_ACCESS_KEY", + ]) + expected = { + "AWS_ACCESS_KEY_ID": "${{ steps.r2-credentials.outputs.AWS_ACCESS_KEY_ID }}", + "AWS_SECRET_ACCESS_KEY": "${{ steps.r2-credentials.outputs.AWS_SECRET_ACCESS_KEY }}", + } + consuming = steps[index + 1] + for key, value in expected.items(): + self.assertEqual(consuming["env"][key], value) + for other in steps[:index] + steps[index + 2:]: + self.assertNotRegex(json.dumps(other), r"steps\.(?:signing-key|r2-credentials)\.outputs") + self.assertNotRegex(other.get("run", ""), r"GITHUB_ENV|BWS_ACCESS_TOKEN") + def test_preparation_verifies_provenance_before_any_secrets(self): verify = workflow("agent-updates-publish.yml")["jobs"]["verify"] source = "\n".join(step.get("run", "") for step in verify["steps"]) diff --git a/scripts/ci/test_signing_workflows.py b/scripts/ci/test_signing_workflows.py index 07957695e..a3520e536 100644 --- a/scripts/ci/test_signing_workflows.py +++ b/scripts/ci/test_signing_workflows.py @@ -36,12 +36,15 @@ "AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID", }, "zapstore-publishing": {"ZAPSTORE_SIGN_WITH"}, - "agent-update-signing": {"AGENT_UPDATE_SIGNING_KEY"}, + "updates-production": {"AGENT_UPDATES_BWS_ACCESS_TOKEN"}, } SIGNING_SECRETS = set.union(*ENVIRONMENTS.values()) CONSUMERS = { "agent-desktop-build.yml": {"macos": "desktop-signing"}, - "agent-updates-publish.yml": {"sign": "agent-update-signing"}, + "agent-updates-publish.yml": { + "reserve": "updates-production", "sign": "updates-production", + "publish": "updates-production", + }, "desktop-build.yml": { "build-macos": "desktop-signing", "build-linux": "desktop-signing", "build-windows": "windows-signing", @@ -290,19 +293,20 @@ def test_missing_wrong_and_dynamic_environments_fail_closed(self): check_boundary({**reference, "environment": environment}) check_boundary({**reference, "environment": "apple-signing"}) - def test_agent_update_key_is_exclusive_to_its_publisher_signing_job(self): + def test_agent_update_bws_access_is_exclusive_to_its_publisher_jobs(self): consumers = [] for name, config in workflows().items(): for job_name, job in config["jobs"].items(): - if "AGENT_UPDATE_SIGNING_KEY" in signing_references(job): + if "AGENT_UPDATES_BWS_ACCESS_TOKEN" in signing_references(job): consumers.append((name, job_name)) - self.assertEqual(consumers, [("agent-updates-publish.yml", "sign")]) - reference = {"steps": [{"env": {"KEY": "${{ secrets.AGENT_UPDATE_SIGNING_KEY }}"}}]} + self.assertEqual(consumers, [("agent-updates-publish.yml", job) + for job in ("reserve", "sign", "publish")]) + reference = {"steps": [{"with": {"access_token": "${{ secrets.AGENT_UPDATES_BWS_ACCESS_TOKEN }}"}}]} for environment in (None, "desktop-signing", "agent-updates-production", "${{ inputs.environment }}"): with self.subTest(environment=environment), self.assertRaises(ValueError): check_boundary({**reference, "environment": environment}) - check_boundary({**reference, "environment": "agent-update-signing"}) + check_boundary({**reference, "environment": "updates-production"}) if __name__ == "__main__": diff --git a/services/agent-updates/README.md b/services/agent-updates/README.md index b91d44bff..e57ac04d0 100644 --- a/services/agent-updates/README.md +++ b/services/agent-updates/README.md @@ -85,9 +85,10 @@ The owner must prepare and approve these inputs before activating delivery: - A private existing Agent bucket, no `r2.dev`/bucket custom domain, retention and immutable-object overwrite/delete policy protecting release prefixes. -- Separate Agent-only macOS package signing, update-signing and R2 publication - environments with required reviewers and master-only branch policy. Signing - uses new Agent primary/offline backup keys; never Research's key. +- Reuse the existing `desktop-signing` environment for macOS package signing + and `updates-production` for Agent reservation, update signing and publication. + Preserve their current protection policies and Research configuration. Update + signing uses new Agent primary/offline backup keys; never Research's key. - Bucket-scoped S3 Object Read & Write credentials, an approved account/bucket, public primary key and exact HTTPS origin. R2 credentials cannot deploy Workers. - An existing Agent Worker and scoped Worker Editor deployment credential; @@ -97,21 +98,30 @@ The owner must prepare and approve these inputs before activating delivery: `https://updates.trymaple.ai/download/agent/stable/*`, separately owner-managed. Confirm zone/account/proxied DNS, HTTP redirects and edge allowlisting first. -Workflow setup is explicit and initially absent: +Agent-specific workflow inputs are explicit and initially absent. Reuse the +existing BWS access credential; no new machine accounts or access tokens are +required by this workflow: -| Scope | Required input | -| ------------------------------------------------ | ----------------------------------------------------------------------------- | -| Repository variables | `AGENT_UPDATES_ENABLED=true`, `AGENT_UPDATE_PUBLIC_KEY` (public minisign key) | -| `agent-updates-production` environment variables | `AGENT_R2_ACCOUNT_ID`, `AGENT_R2_BUCKET` | -| `agent-updates-production` secrets | `AGENT_R2_ACCESS_KEY_ID`, `AGENT_R2_SECRET_ACCESS_KEY` | -| `agent-update-signing` secret | `AGENT_UPDATE_SIGNING_KEY` (dedicated unencrypted minisign key-file contents) | +| Scope | Required input | +| ----------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| Repository variables | `AGENT_UPDATES_ENABLED=true`, `AGENT_UPDATE_PUBLIC_KEY` (public minisign key) | +| `updates-production` environment variables | `AGENT_R2_ACCOUNT_ID`, `AGENT_R2_BUCKET`, `AGENT_UPDATE_SIGNING_KEY_ID`, `AGENT_R2_ACCESS_KEY_ID_SECRET_ID`, `AGENT_R2_SECRET_ACCESS_KEY_SECRET_ID` | +| `updates-production` environment secret | `AGENT_UPDATES_BWS_ACCESS_TOKEN` (existing BWS access credential) | +| BWS items selected by the environment variables | Dedicated unencrypted Agent minisign key-file contents and Agent-bucket S3 access-key pair | + +The pinned BWS action follows the existing PCR workflow: `set_env: false`, with +outputs passed only to the consuming step after the pinned tools are realized. +The sign step maps only the Agent update key; reservation/publication map only +the Agent bucket credentials. These mappings do not reduce the existing BWS +credential's grants or the GitHub environment's secret context. Shared access +therefore remains a trusted workflow boundary, not separate credential authority. The public key must also be pinned in both the chosen build's Prod source profile and the publisher's trusted master profile. An empty source key list fails closed; setting workflow variables cannot replace those pins. Production verification uses the fixed `https://updates.trymaple.ai` origin. No Worker deployment token belongs -in any publication environment. Protect both environments with required reviewers -and master-only branch policy before activation. +in the Agent publication steps. Preserve the existing environment's policies; +the Agent workflow additionally enforces manual dispatch from trusted `master`. Before/after route activation compare Research's metadata bytes and all six GET/HEAD download redirects, accounting for legitimate Research releases. In an diff --git a/services/agent-updates/publisher/README.md b/services/agent-updates/publisher/README.md index ab6de5145..8ca33c1f3 100644 --- a/services/agent-updates/publisher/README.md +++ b/services/agent-updates/publisher/README.md @@ -7,7 +7,13 @@ downloaded applications. Run it from a trusted checkout with the root pinned The workflow separates read-only package verification, bucket reservation, signing and publication. Reservation/publication use bucket-scoped S3 credentials; -the signing stage receives only a dedicated unencrypted minisign secret-key file. +the signing process receives a dedicated unencrypted minisign secret-key file. +Credentialed jobs reuse the existing `updates-production` environment and BWS +access credential. The pinned BWS action does not export to later steps; each +consuming step explicitly maps only the values it needs. This does not narrow +the underlying BWS grants or the environment's existing secret context. Pinned +tools are realized before retrieval. No new machine accounts or access tokens +are required; the exact item-ID inputs are listed in the [service setup](../README.md#publication-and-authority). The public key must already be pinned both in the selected build's source profile and in the publisher's trusted source profile. An empty key list blocks publication. From 1806b25e558c648b28b88bb79b744eafd7113399 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:47:01 +0000 Subject: [PATCH 09/12] Reuse existing desktop signing environment for Agent packages --- .github/workflows/agent-desktop-build.yml | 2 +- apps/maple-agent/docs/desktop-builds.md | 12 ++++++------ apps/maple-agent/docs/updates.md | 7 ++++--- docs/app-signing-environments.md | 24 +++++++++++++---------- scripts/ci/test_agent_workflows.py | 2 +- scripts/ci/test_signing_workflows.py | 6 +----- 6 files changed, 27 insertions(+), 26 deletions(-) diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index 3527e6f66..6beb32a50 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -127,7 +127,7 @@ jobs: name: Signed macOS (${{ matrix.variant }}) needs: [changes, macos-build] if: ${{ always() && !cancelled() && needs.macos-build.result == 'success' && github.repository == 'MaplePrivacyLabs/Maple' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') }} - environment: agent-desktop-signing + environment: desktop-signing permissions: contents: read id-token: write diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index a8bd70241..986412c83 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -134,9 +134,9 @@ nix develop --no-update-lock-file . -c ./scripts/verify-updater-archive.sh dist/ ``` Use `prod` for the production service profile. Official macOS signing is run -only by the trusted CI job using the separate `agent-desktop-signing` GitHub -environment. Application compilation runs on a separate runner; the trusted -updater scanner compiles before the job step receiving Apple credentials. Linux +only by the trusted master CI job, reusing the existing `desktop-signing` GitHub +environment and its five Apple secrets. Application compilation runs on a +separate runner; the trusted updater scanner compiles before the job step receiving Apple credentials. Linux packaging needs no Apple credentials. ## Distribution and update boundaries @@ -148,7 +148,7 @@ The client accepts only its signed Agent product/channel/target manifest, with keys embedded in the selected Prod profile. Empty production keys disable update traffic until owner setup is complete. See [updater activation](updates.md). -Configure `agent-desktop-signing` and its master-only protection before merging -the workflow's environment change. Keep Research's signing environment, updater -key, publication workflow and compatibility URLs intact. No Research install is +No new app-signing environment or Apple credential migration is needed. Keep the +existing `desktop-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/docs/updates.md b/apps/maple-agent/docs/updates.md index 4a64bfdb5..807ea4bbb 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -99,9 +99,10 @@ Before enabling the feed, prepare and review these concrete operations: commit only the two reviewed public keys in `release-profiles.json`. Rebuild the initial `0.0.1` package with those pins. Test keys must never become Prod pins. Key rotation is a separately reviewed release operation. -2. Protect `agent-desktop-signing` for trusted master builds and supply its five - Apple signing/notarization secrets. Keep Research's `desktop-signing` - environment and its credentials/policies unchanged. +2. Reuse the existing `desktop-signing` environment and its five Apple + signing/notarization secrets for the trusted master Agent packaging job. No + new app-signing environment, Apple credentials or BWS migration is required; + keep the existing environment's credentials and policies unchanged. 3. Provision the Agent R2 bucket and Worker. Bind only the Agent bucket. Create the two HTTPS routes for `/agent/stable/*` and `/download/agent/stable/*` as an owner operation. Keep routes out of routine diff --git a/docs/app-signing-environments.md b/docs/app-signing-environments.md index 09b0115b1..39f0d1439 100644 --- a/docs/app-signing-environments.md +++ b/docs/app-signing-environments.md @@ -6,8 +6,7 @@ Branch protection on `master` alone does not protect repository-level secrets. | Environment | Credentials | Jobs | | --- | --- | --- | -| `agent-desktop-signing` | `APPLE_CERTIFICATE`, `APPLE_CERTIFICATE_PASSWORD`, `APPLE_ID`, `APPLE_ID_PASSWORD`, `APPLE_TEAM_ID` | Agent macOS Dev/Prod packages from trusted master | -| `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` | macOS/Linux master and release builds | +| `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 | @@ -27,19 +26,24 @@ Pages/updater deployments. The same environments also serve owner-controlled `v*` release tags, so their signing jobs likewise have no separate approval prompt. Creating a release and controlling its distribution remain separate boundaries. -`Maple Agent Desktop Builds` uses the separate `agent-desktop-signing` environment -for its trusted `master` macOS Dev/Prod jobs. Configure its master-only policy and -Apple credentials before merging that workflow change; it has no Research Tauri key. -Agent update-manifest signing and distribution use separate protected environments. -The same Developer ID publisher -signs distinct Agent bundle identities; it does not replace Research's identity. +`Maple Agent Desktop Builds` reuses the existing `desktop-signing` environment +and its five `APPLE_*` secrets for trusted `master` macOS Dev/Prod packaging. +No new Apple credentials, environment, BWS retrieval, or credential migration is +required. Keep that environment's existing secrets and policies unchanged. The +Agent packaging step references only those Apple secrets; sharing the environment +does not isolate it from other credentials authorized to that job's trusted code. +Agent update manifests use an independent Agent key, separate from Research's +Tauri key. The same Developer ID publisher signs distinct Agent bundle identities; +it does not replace Research's identity. Agent contributor packaging, Linux compilation, and macOS compilation receive no Apple or updater signing secrets. A fresh macOS packaging job downloads the binary from the same workflow run and validates its profile and embedded source revision against the exact checkout by reading a dedicated Mach-O metadata section. It never executes artifact code, including -before the Apple credential step, and does not restore compilation caches or run -Cargo. App launches and Swift runtime checks run only on verifiers without Apple +before the Apple credential step, and does not restore compilation caches or +compile the application. The trusted updater-only archive scanner compiles before +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. The macOS signing job has `contents: read`, `id-token: write`, and diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 91bd154fa..e90339ee0 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -153,7 +153,7 @@ def test_only_trusted_master_can_receive_apple_signing_credentials(self): self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") jobs = config["jobs"] signed = jobs["macos"] - self.assertEqual(signed["environment"], "agent-desktop-signing") + self.assertEqual(signed["environment"], "desktop-signing") self.assertEqual(signed["runs-on"], "macos-26") condition = " ".join(signed["if"].split()) self.assertIn(self.MASTER_GUARD, condition) diff --git a/scripts/ci/test_signing_workflows.py b/scripts/ci/test_signing_workflows.py index e9fddce47..3220d7252 100644 --- a/scripts/ci/test_signing_workflows.py +++ b/scripts/ci/test_signing_workflows.py @@ -16,10 +16,6 @@ ROOT = Path(__file__).resolve().parents[2] ENVIRONMENTS = { - "agent-desktop-signing": { - "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_ID", - "APPLE_ID_PASSWORD", "APPLE_TEAM_ID", - }, "desktop-signing": { "TAURI_SIGNING_PRIVATE_KEY", "TAURI_SIGNING_PRIVATE_KEY_PASSWORD", "APPLE_CERTIFICATE", "APPLE_CERTIFICATE_PASSWORD", "APPLE_ID", @@ -44,7 +40,7 @@ } SIGNING_SECRETS = set.union(*ENVIRONMENTS.values()) CONSUMERS = { - "agent-desktop-build.yml": {"macos": "agent-desktop-signing"}, + "agent-desktop-build.yml": {"macos": "desktop-signing"}, "agent-updates-publish.yml": {"sign": "agent-update-signing"}, "desktop-build.yml": { "build-macos": "desktop-signing", "build-linux": "desktop-signing", From c3ac4dfafbb9024032889ab21f956d9aafe34b21 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:54:37 +0000 Subject: [PATCH 10/12] docs(agent): describe existing publication environment reuse --- apps/maple-agent/docs/updates.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index 807ea4bbb..6b6dfa65a 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -108,9 +108,12 @@ Before enabling the feed, prepare and review these concrete operations: `/download/agent/stable/*` as an owner operation. Keep routes out of routine Worker deployment configuration so deploy tokens do not need route-write authority. Configure HTTP-to-HTTPS handling in the zone. -4. Protect `agent-update-signing` and `agent-updates-production`, restrict them - to trusted master, and configure the independently scoped signing and R2 - credentials documented by the service. Publication must verify that the +4. Reuse the existing `updates-production` approval environment and BWS access + credential for Agent reservation, update signing and publication. Configure + the Agent signing-key and bucket-item IDs documented by the service without + changing the existing environment's policies or Research credentials. The + workflow maps BWS outputs only into the consuming step; shared BWS grants and + environment access remain a trusted workflow boundary. Publication must verify that the selected artifact's embedded pins and trusted master both contain the chosen public key; an environment variable is not a substitute for compiled trust. 5. Deploy with the narrowly scoped existing-Worker token and confirm the R2 From c40d33644c145eeee1a74d74995021a4768c06dc Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:01:15 +0000 Subject: [PATCH 11/12] chore(agent-updates): bind provisioned private release bucket --- scripts/ci/test_agent_updates_workflows.py | 2 +- services/agent-updates/README.md | 10 ++++++---- services/agent-updates/src/dry-run.ts | 4 ++-- services/agent-updates/wrangler.jsonc | 6 +++--- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/scripts/ci/test_agent_updates_workflows.py b/scripts/ci/test_agent_updates_workflows.py index c01fb78b5..6769b7f60 100644 --- a/scripts/ci/test_agent_updates_workflows.py +++ b/scripts/ci/test_agent_updates_workflows.py @@ -160,7 +160,7 @@ def test_routes_and_worker_resources_require_separate_setup(self): self.assertNotIn("assets", config) self.assertNotIn("env", config) self.assertEqual(config["r2_buckets"], [{ - "binding": "AGENT_RELEASES", "bucket_name": "REQUIRES-OWNER-APPROVED-AGENT-BUCKET", + "binding": "AGENT_RELEASES", "bucket_name": "maple-agent-updates", }]) # Keep an accidental deploy out of the ordinary package commands. package = json.loads((ROOT / "services/agent-updates/package.json").read_text()) diff --git a/services/agent-updates/README.md b/services/agent-updates/README.md index e57ac04d0..553543b9d 100644 --- a/services/agent-updates/README.md +++ b/services/agent-updates/README.md @@ -5,9 +5,11 @@ Independent Agent Worker and R2 publisher. Research's `services/updates/`, are unchanged. This code creates no Cloudflare resources and starts no releases. See [publisher tooling](publisher/README.md) for exact commands and retry behavior. -The checked-in bucket binding is deliberately a placeholder. There is no deploy -script or automatic deployment workflow. `workers_dev` and preview URLs are off; -routine configuration must never contain `route`, `routes` or a Custom Domain. +The checked-in binding names the owner-provisioned private `maple-agent-updates` +bucket. This is configuration, not evidence of Worker deployment or activated +delivery. There is no deploy script or automatic deployment workflow. +`workers_dev` and preview URLs are off; routine configuration must never contain +`route`, `routes` or a Custom Domain. Local checks use a separate fixture bucket. ## Local verification @@ -98,7 +100,7 @@ The owner must prepare and approve these inputs before activating delivery: `https://updates.trymaple.ai/download/agent/stable/*`, separately owner-managed. Confirm zone/account/proxied DNS, HTTP redirects and edge allowlisting first. -Agent-specific workflow inputs are explicit and initially absent. Reuse the +Agent-specific workflow inputs are explicit. Reuse the existing BWS access credential; no new machine accounts or access tokens are required by this workflow: diff --git a/services/agent-updates/src/dry-run.ts b/services/agent-updates/src/dry-run.ts index 433d8cfa2..d5f7b3f4f 100644 --- a/services/agent-updates/src/dry-run.ts +++ b/services/agent-updates/src/dry-run.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -// Keep the checked-in production config undeployable before owner setup, while -// still bundling and validating code using Wrangler's actual dry-run path. +// Keep local validation independent of the owner-provisioned production bucket. +// Bundle and validate code using Wrangler's actual dry-run path. // This checked-in config uses whole-line comments and trailing commas only. // Unsupported JSONC syntax fails closed instead of weakening deployment guards. const config: unknown = JSON.parse( diff --git a/services/agent-updates/wrangler.jsonc b/services/agent-updates/wrangler.jsonc index a94a0e9d4..557fe145c 100644 --- a/services/agent-updates/wrangler.jsonc +++ b/services/agent-updates/wrangler.jsonc @@ -5,12 +5,12 @@ "compatibility_date": "2026-08-24", "workers_dev": false, "preview_urls": false, - // Deliberately undeployable placeholder. Approve and configure the existing - // private bucket before deployment. Routes are owner-managed, never deployed. + // Owner-provisioned private Agent bucket. This config does not activate delivery; + // Worker deployment and owner-managed Routes require separate authorization. "r2_buckets": [ { "binding": "AGENT_RELEASES", - "bucket_name": "REQUIRES-OWNER-APPROVED-AGENT-BUCKET", + "bucket_name": "maple-agent-updates", }, ], } From c96346f89fadd0ed872032b66f0401e08704f4a5 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:48:46 +0000 Subject: [PATCH 12/12] Pin Agent production updater primary and backup public keys --- .agents/skills/develop-maple-agent/SKILL.md | 11 ++++--- apps/maple-agent/README.md | 9 ++++-- apps/maple-agent/app/src/profile.rs | 32 +++++++++++++++++++ apps/maple-agent/docs/desktop-builds.md | 9 ++++-- apps/maple-agent/docs/release-profiles.md | 13 +++++--- apps/maple-agent/docs/updates.md | 21 +++++++----- apps/maple-agent/release-profiles.json | 5 ++- apps/maple-agent/release_profile.rs | 8 +++-- .../scripts/test_release_packaging.py | 13 ++++++++ 9 files changed, 94 insertions(+), 27 deletions(-) diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index c9a5830eb..5e1385278 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -99,10 +99,13 @@ ownership at the layer implementing the effect. Never treat a passing source import or a native login screen as authenticated chat or containment proof. Packaged Agent Prod uses its independently signed R2 update feed with compiled -public-key pins. Dev and unpackaged builds have no production updater. Empty -production pins disable update traffic until reviewed setup; do not substitute -a test key or inherit feed/key overrides from the launch environment. Embedded -profiles, bundle IDs and state namespaces remain separate; packaged profiles +primary and backup public-key pins. Packaged Prod checks the compiled feed; +publication remains disabled pending owner service setup. Invitations still +require the signed installed-update rehearsal. +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 +remain separate; packaged profiles never adopt legacy Agent or Research state. See [`desktop builds`](../../../apps/maple-agent/docs/desktop-builds.md) for packaging commands and the protected-master signing/artifact contract. diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 5ddd3dd2c..306f37a19 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -564,9 +564,12 @@ check. 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 public keys; inherited environment variables cannot replace -them. Until approved production keys are pinned, the UI reports that updates -are not configured and makes no update requests. +the update URL and reviewed primary/backup public keys; inherited environment +variables cannot replace them. Packaged Prod builds check the compiled feed, while +publication remains disabled pending owner service setup. Invitations still +require the signed installed-update rehearsal. +An empty-key profile still reports that updates are not configured and makes no +update requests. Updates authenticate product, channel, version, target, byte size and SHA-256 before staging. Automatic downloads default on; installation requires the diff --git a/apps/maple-agent/app/src/profile.rs b/apps/maple-agent/app/src/profile.rs index 6bc3e1212..2c1e07751 100644 --- a/apps/maple-agent/app/src/profile.rs +++ b/apps/maple-agent/app/src/profile.rs @@ -104,6 +104,38 @@ mod tests { assert_eq!(info["prerelease"], PRERELEASE); } + #[test] + fn prod_update_pins_are_accepted_and_reject_the_test_signer() { + use maple_agent_updater::{Config, Target, Updater, manifest::verify_envelope}; + + let (_, prod) = config::select_profile(Some("prod"), None).unwrap(); + let feed = prod.update_feed_url.unwrap().parse().unwrap(); + let temporary = tempfile::tempdir().unwrap(); + let installed = temporary.path().join("installed"); + std::fs::create_dir(&installed).unwrap(); + // Constructing the actual client parses every configured minisign key. + // It writes only isolated temporary state and makes no network request. + Updater::new(Config { + feed_url: feed, + public_keys: prod.update_public_keys.clone(), + current_version: "0.0.1".parse().unwrap(), + target: Target::DarwinAarch64, + installed_path: installed, + state_dir: temporary.path().join("updates"), + }) + .unwrap(); + assert!( + verify_envelope( + include_bytes!( + "../../crates/maple-agent-updater/tests/fixtures/signed-envelope.json" + ), + &"https://updates.trymaple.ai/".parse().unwrap(), + &prod.update_public_keys, + ) + .is_err() + ); + } + #[test] fn runtime_dependency_bakes_the_same_pcr_trust_as_app_metadata() { let actual = maple_agent::open_secret_config::configured_pcr0_environment().unwrap(); diff --git a/apps/maple-agent/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 986412c83..6e9c27685 100644 --- a/apps/maple-agent/docs/desktop-builds.md +++ b/apps/maple-agent/docs/desktop-builds.md @@ -22,7 +22,7 @@ previews. | Profile | Application | macOS bundle ID | Configuration/data namespace | Update delivery | | --- | --- | --- | --- | --- | | Dev | Maple Agent Dev | `cloud.opensecret.maple.agent.dev` | `maple-agent-dev` | Disabled; master Actions artifacts | -| Prod | Maple Agent | `cloud.opensecret.maple.agent` | `maple-agent-prod` | Signed Agent feed, after production keys are configured | +| Prod | Maple Agent | `cloud.opensecret.maple.agent` | `maple-agent-prod` | Signed Agent feed with primary/backup pins; 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 @@ -145,8 +145,11 @@ These builds remain Actions downloads and do not advance an updater feed. 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 -keys embedded in the selected Prod profile. Empty production keys disable update -traffic until owner setup is complete. See [updater activation](updates.md). +the primary and backup keys embedded in the Prod profile. Packaged Prod builds +check that feed; publication remains disabled pending owner service setup, and +invitations still require the signed update rehearsal. 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, diff --git a/apps/maple-agent/docs/release-profiles.md b/apps/maple-agent/docs/release-profiles.md index 3bb8ef9e2..c37f0ea97 100644 --- a/apps/maple-agent/docs/release-profiles.md +++ b/apps/maple-agent/docs/release-profiles.md @@ -56,11 +56,14 @@ revision with a possible `-dirty` suffix; `source_sha` is the full source commit Tarball/pure Nix builds without Git report `unknown` for unavailable revisions. Packaged Prod builds use only the compiled Agent stable feed at -`https://updates.trymaple.ai/agent/stable/latest.json`. The profile contains no -production signing keys yet, so it reports **Updates not configured** and makes -no update requests until the separately approved key setup is complete. Active -trust configuration requires two distinct public keys; the backup supports -planned rotation and does not revoke a compromised accepted primary. +`https://updates.trymaple.ai/agent/stable/latest.json`. The profile pins the +reviewed primary and backup public keys, enabling packaged Prod discovery after +launch. Publication remains disabled pending owner service setup; invitations +still require the signed update rehearsal. An empty-key profile reports +**Updates not configured** and +makes no update requests. Active trust requires two distinct public keys; the +backup supports planned rotation and does not revoke a compromised accepted +primary. 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 diff --git a/apps/maple-agent/docs/updates.md b/apps/maple-agent/docs/updates.md index 6b6dfa65a..8dce7bf57 100644 --- a/apps/maple-agent/docs/updates.md +++ b/apps/maple-agent/docs/updates.md @@ -2,10 +2,14 @@ Packaged Prod builds use the Agent-only signed update service at `https://updates.trymaple.ai/agent/stable/latest.json`. Dev and unpackaged -builds do not check that service. The production public-key list is intentionally -empty until reviewed owner setup; those builds show **Updates not configured** -and make no updater requests. Environment variables cannot supply a feed or -signing key. `MAPLE_DISABLE_UPDATE_CHECK=1` can only disable checks. +builds do not check that service. The Prod profile now pins the reviewed primary +and backup public keys, so packaged Prod builds check the compiled feed after +launch. Publication remains disabled pending owner service and routing setup; +pinning keys does not deploy or publish anything. Invitations still require the +signed installed-update rehearsal below. Environment +variables cannot supply a feed or signing key. `MAPLE_DISABLE_UPDATE_CHECK=1` +can only disable checks. A profile with no keys still shows **Updates not +configured** and makes no updater requests. This feature does not change Research's updater, update Worker, publisher, release selection, signing configuration, or download paths. Agent publication @@ -95,10 +99,11 @@ Implementation PRs do not create infrastructure, production signing keys, GitHub environments, routes, credentials, releases, or production publications. Before enabling the feed, prepare and review these concrete operations: -1. Create two dedicated Agent minisign key pairs, retain the backup offline, and - commit only the two reviewed public keys in `release-profiles.json`. Rebuild - the initial `0.0.1` package with those pins. Test keys must never become Prod - pins. Key rotation is a separately reviewed release operation. +1. The two reviewed Agent public keys are pinned in `release-profiles.json`; + the primary and backup are distinct from test and Research keys. Build the + initial `0.0.1` package from this pinned source. Keep the backup private key + offline and unavailable to CI. Key rotation remains a separately reviewed + release operation. 2. Reuse the existing `desktop-signing` environment and its five Apple signing/notarization secrets for the trusted master Agent packaging job. No new app-signing environment, Apple credentials or BWS migration is required; diff --git a/apps/maple-agent/release-profiles.json b/apps/maple-agent/release-profiles.json index 70e8604ac..67e6ea661 100644 --- a/apps/maple-agent/release-profiles.json +++ b/apps/maple-agent/release-profiles.json @@ -26,7 +26,10 @@ "pcr_environment": "production", "update_tag_prefix": "maple-agent-v", "update_feed_url": "https://updates.trymaple.ai/agent/stable/latest.json", - "update_public_keys": [], + "update_public_keys": [ + "RWSEawHGDqhs8hlPvW08LCDXexMVkxqCtKhorNHTVpgMmYlls07md+mc", + "RWR3nhVL8nlVmwQBTSKl/dl6a0V3eseG260Be3/aYVb4+K8MgBfyOyN4" + ], "prerelease": false } } diff --git a/apps/maple-agent/release_profile.rs b/apps/maple-agent/release_profile.rs index f59c0f677..74dc0e3fc 100644 --- a/apps/maple-agent/release_profile.rs +++ b/apps/maple-agent/release_profile.rs @@ -266,9 +266,8 @@ mod tests { prod.update_feed_url.as_deref(), Some("https://updates.trymaple.ai/agent/stable/latest.json") ); - // The reviewed source may be built before production key setup. The - // updater fails closed until separately approved keys are pinned. - assert!(prod.update_public_keys.is_empty()); + assert_eq!(prod.update_public_keys.len(), 2); + assert_ne!(prod.update_public_keys[0], prod.update_public_keys[1]); for profile in [dev, prod] { assert_ne!(profile.data_namespace, "maple-agent"); assert_ne!(profile.data_namespace, "maple-gpui"); @@ -320,6 +319,9 @@ mod tests { fn updater_profile_accepts_disabled_key_setup_and_rejects_empty_or_duplicate_keys() { let original = profiles().unwrap(); assert!(validate_profiles(&original).is_ok()); + let mut disabled = original.clone(); + disabled.get_mut("prod").unwrap().update_public_keys.clear(); + assert!(validate_profiles(&disabled).is_ok()); for keys in [ vec![" ".into()], vec!["single".into()], diff --git a/apps/maple-agent/scripts/test_release_packaging.py b/apps/maple-agent/scripts/test_release_packaging.py index 7c21d79b7..51c0ee533 100644 --- a/apps/maple-agent/scripts/test_release_packaging.py +++ b/apps/maple-agent/scripts/test_release_packaging.py @@ -308,6 +308,19 @@ def test_update_feed_and_keys_cannot_drift_from_the_packaged_profile(self): release_info.read_info("dev", self.path) self.info[field] = original + def test_prod_metadata_requires_the_configured_primary_and_backup_pins(self): + prod = json.loads((SCRIPTS.parent / "release-profiles.json").read_text())["prod"] + self.info = {**prod, "profile": "prod", "version": "0.0.1", + "source_sha": "a" * 40, "git_revision": "a" * 8} + self.write_info() + self.assertEqual(len(release_info.read_info("prod", self.path)["update_public_keys"]), 2) + for keys in ([], prod["update_public_keys"][:1], + list(reversed(prod["update_public_keys"]))): + self.info["update_public_keys"] = keys + self.write_info() + with self.subTest(keys=keys), self.assertRaisesRegex(ValueError, "release profile"): + release_info.read_info("prod", self.path) + def test_backup_requires_a_distinct_public_key_not_a_changed_comment_or_id(self): def key(point, key_id=b"12345678", comment="fixture"): packet = base64.b64encode(b"Ed" + key_id + point).decode()