diff --git a/.agents/skills/develop-maple-agent/SKILL.md b/.agents/skills/develop-maple-agent/SKILL.md index 9d3ca2bd3..5e1385278 100644 --- a/.agents/skills/develop-maple-agent/SKILL.md +++ b/.agents/skills/develop-maple-agent/SKILL.md @@ -98,14 +98,21 @@ 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 +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. -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 44d6d146a..c5b26deba 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -166,6 +166,9 @@ jobs: path: apps/maple-agent/target - 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: @@ -174,6 +177,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: @@ -223,8 +229,14 @@ jobs: path: apps/maple-agent/target - 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/Cargo.lock b/apps/maple-agent/Cargo.lock index ab0823b07..1b4a2c02a 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -431,7 +431,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c" dependencies = [ "base64ct", - "blake2", + "blake2 0.11.0", "cpufeatures 0.3.0", "password-hash", "rayon", @@ -1183,6 +1183,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 = "blake2" version = "0.11.0" @@ -2411,6 +2420,7 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "curve25519-dalek-derive", + "digest 0.10.7", "fiat-crypto 0.2.9", "rustc_version", "subtle", @@ -2889,6 +2899,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" @@ -2900,6 +2920,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" @@ -2907,7 +2941,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", @@ -3289,6 +3323,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" @@ -4902,7 +4946,7 @@ dependencies = [ "ctutils", "data-encoding", "derive_more", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "futures-util", "getrandom 0.4.3", "http", @@ -4948,7 +4992,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", @@ -5711,7 +5755,7 @@ dependencies = [ [[package]] name = "maple-agent" -version = "0.1.0" +version = "0.0.1" dependencies = [ "agent-client-protocol", "anyhow", @@ -5763,7 +5807,7 @@ dependencies = [ [[package]] name = "maple-agent-app" -version = "0.1.0" +version = "0.0.1" dependencies = [ "axum", "base64 0.22.1", @@ -5781,6 +5825,7 @@ dependencies = [ "libc", "log", "maple-agent", + "maple-agent-updater", "maple-billing", "maple-proxy", "maple-sdk", @@ -5815,9 +5860,37 @@ dependencies = [ "zeroize", ] +[[package]] +name = "maple-agent-updater" +version = "0.0.1" +dependencies = [ + "anyhow", + "base64 0.22.1", + "blake2 0.10.6", + "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", @@ -6009,6 +6082,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" @@ -7596,7 +7675,7 @@ dependencies = [ [[package]] name = "pi-agent-core" -version = "0.1.0" +version = "0.0.1" dependencies = [ "async-trait", "futures-util", @@ -7609,7 +7688,7 @@ dependencies = [ [[package]] name = "pi-ai" -version = "0.1.0" +version = "0.0.1" dependencies = [ "async-trait", "bytes", @@ -7626,7 +7705,7 @@ dependencies = [ [[package]] name = "pi-coding-agent" -version = "0.1.0" +version = "0.0.1" dependencies = [ "async-trait", "base64 0.22.1", @@ -9976,7 +10055,7 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "store-bench" -version = "0.1.0" +version = "0.0.1" dependencies = [ "anyhow", "base64 0.22.1", @@ -10357,6 +10436,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" @@ -12835,6 +12925,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 87189280e..9146bce95 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", "crates/store-bench", "app"] +members = ["crates/maple-agent", "crates/maple-billing", "crates/maple-agent-updater", "crates/pi-ai", "crates/pi-agent-core", "crates/pi-coding-agent", "crates/store-bench", "app"] [workspace.package] edition = "2024" -version = "0.1.0" +version = "0.0.1" license = "MIT" [workspace.dependencies] diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index a7a6ba30d..407438b58 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -511,7 +511,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 | @@ -522,8 +522,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 @@ -585,17 +584,27 @@ 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 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 +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 442ce47c9..d315157c2 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -38,6 +38,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/backend.rs b/apps/maple-agent/app/src/backend.rs index a0382899c..4d840550b 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -18,10 +18,15 @@ use serde::{Serialize, de::DeserializeOwned}; use std::{ collections::HashMap, future::Future, - sync::{Arc, atomic::AtomicU64}, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU64, 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, @@ -29,6 +34,7 @@ pub struct AgentBackend { api_url: String, controller: Option>, remote_host: Option, + retired_after_update: Arc, subscription_sequence: AtomicU64, } @@ -265,6 +271,7 @@ impl AgentBackend { api_url, controller: None, remote_host: None, + retired_after_update: Arc::new(AtomicBool::new(false)), subscription_sequence: AtomicU64::new(0), }) } @@ -279,6 +286,7 @@ impl AgentBackend { api_url: "http://127.0.0.1:1".into(), controller: None, remote_host: None, + retired_after_update: Arc::new(AtomicBool::new(false)), subscription_sequence: AtomicU64::new(0), } } @@ -294,22 +302,62 @@ impl AgentBackend { api_url, controller: None, remote_host: None, + retired_after_update: Arc::new(AtomicBool::new(false)), subscription_sequence: AtomicU64::new(0), }) } 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(|client| BackendClient::Host(Box::new(client))) + }) + .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(Box::new(client)), + client, api_url: self.api_url.clone(), controller: Some(self.clone()), remote_host: Some(peer), + retired_after_update: self.retired_after_update.clone(), subscription_sequence: AtomicU64::new(0), })) } + + /// 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); + } + + #[cfg(feature = "desktop")] + pub(crate) fn is_retired_after_update(&self) -> bool { + self.retired_after_update.load(Ordering::Acquire) + } + + 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() } @@ -329,6 +377,7 @@ impl AgentBackend { method: &str, params: impl Serialize, ) -> Result { + self.ensure_active()?; // Speech, account UI and each viewer's remote-sidebar preferences // belong to the controller. // All task operations use the selected host's separate connection. @@ -373,6 +422,7 @@ impl AgentBackend { request: AgentSendMessageRequest, id: String, ) -> Result { + self.ensure_active()?; match &self.client { BackendClient::Host(client) => { client @@ -397,6 +447,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)] @@ -428,6 +479,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)] @@ -475,6 +529,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()); } @@ -643,6 +698,134 @@ pub(crate) async fn dispatch_remote_inner( 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 remote_sidebar_preferences_stay_in_the_controller_store() { fn store(root: &std::path::Path) -> Arc { @@ -662,6 +845,7 @@ mod tests { api_url: "http://127.0.0.1:1".into(), controller: None, remote_host: None, + retired_after_update: Arc::new(AtomicBool::new(false)), subscription_sequence: AtomicU64::new(0), } } diff --git a/apps/maple-agent/app/src/desktop.rs b/apps/maple-agent/app/src/desktop.rs index bf2e69871..9b0c22c2f 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, @@ -133,6 +133,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, @@ -658,6 +660,7 @@ impl MapleApp { self.store_load_epoch = self.store_load_epoch.wrapping_add(1); self.password_settings_refresh_pending = false; self.store_ready = false; + self.updater.set_auto_download(false); crate::settings::clear_decrypted_cache(); ui::spell::clear_user_words(); self.settings = crate::settings::AppSettings::default(); @@ -753,6 +756,7 @@ impl MapleApp { Ok(Ok((settings, status))) => { let _ = this.update(cx, |this, cx| { if this.store_load_epoch != epoch + || status.state != maple_agent::store::StoreState::Open || this.backend.store_state() != maple_agent::store::StoreState::Open { return; @@ -766,6 +770,14 @@ impl MapleApp { crate::settings::attach_backend(this.backend.clone(), settings); this.settings = crate::settings::load_settings(); this.store_ready = true; + let updater = this.updater.clone(); + let automatic = this.settings.automatic_update_downloads; + updater.set_auto_download(automatic); + // Retain the previous app until current Open-store bootstrap + // succeeds; never use provisional download preferences. + drop(this.backend.spawn(async move { + updater.run().await; + })); if std::mem::replace(&mut this.restore_window_geometry, false) && let Some(saved) = this .settings @@ -900,18 +912,139 @@ 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 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; } - cx.notify(); + if let Some(chat) = &self.local_chat { + chat.update(cx, |chat, cx| chat.set_update(snapshot.clone(), cx)); + } + 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 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 + || cx + .try_global::() + .is_some_and(|state| state.closing.get()) + { + 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 install_backend = backend.clone(); + let bridge = cx.spawn(async move |this, cx| { + let confirmed = answer.await == Ok(1) + && this + .update(cx, |this, cx| { + if !this.store_ready + || this.backend.store_state() + != maple_agent::store::StoreState::Open + || this.updater.snapshot().state != crate::update::State::Ready + { + return false; + } + if let Some(state) = cx.try_global::() + && state.closing.replace(true) + { + return false; + } + persist_window_state(); + true + }) + .unwrap_or(false); + let installed = if confirmed { + backend + .spawn(async move { controller.install(install_backend).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 { + restart_installed_update(path, cx); + } else if confirmed { + cancel_quit(cx); + } + }); + }); + 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; + controller.set_auto_download(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(_) => controller.download_if_automatic().await, + UpdateAction::Restart | UpdateAction::Relaunch | UpdateAction::ManualDownload => {} + } + })); } /// Tear down the chat screen and return to a fresh login form. A @@ -957,11 +1090,7 @@ impl MapleApp { self.settings.remote_sidebars.clear(); 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()); @@ -1458,6 +1587,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| { @@ -1561,6 +1701,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, _, _: &ui::store_gate::RetryStorePersistence, cx| { @@ -1710,12 +1855,39 @@ impl Render for MapleApp { .flex() .flex_col() .debug_selector(|| "maple-page".to_string()) - .child(match &self.screen { - Screen::Restoring => restoring_view().into_any_element(), - Screen::StoreGate(gate) => gate.clone().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() + .flex_col() + .gap_4() + .p_6() + .child(div().text_xl().child("Update installed")) + .child(self.updater.snapshot().label) + .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::StoreGate(gate) => gate.clone().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(), + } }), ) } @@ -1875,6 +2047,7 @@ impl HostActivity { #[derive(Clone)] struct DesktopLifecycle { backend: Arc, + updater: crate::update::Controller, remote_enabled: Arc, store_resident: Arc, can_background: bool, @@ -1885,6 +2058,44 @@ struct DesktopLifecycle { } impl gpui::Global for DesktopLifecycle {} +/// Recheck final effects: an earlier Quit cannot exit an installation. +fn may_finish_quit(cx: &App) -> bool { + !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) { + let Some(state) = cx.try_global::() else { + return; + }; + if state.updater.restart_path().as_ref() != Some(&path) + || !state.backend.is_retired_after_update() + { + return; + } + state.quit_completed.set(true); + #[cfg(target_os = "macos")] + native_quit::allow_update_restart(); + cx.set_restart_path(path); + cx.restart(); + #[cfg(target_os = "macos")] + native_quit::finish_update_restart(); +} + +fn finish_quit(cx: &mut App) { + if !may_finish_quit(cx) { + cancel_native_quit(); + show_update_about(cx); + return; + } + #[cfg(target_os = "macos")] + native_quit::complete(cx); + #[cfg(not(target_os = "macos"))] + cx.quit(); +} + /// Menu, keyboard, tray and AppKit all enter the same coordinator. The /// frontend remains alive until settings commit and the host releases owner.lock. fn quit_app(cx: &mut App) { @@ -1897,9 +2108,21 @@ fn quit_app(cx: &mut App) { return; } let Some(state) = cx.try_global::().cloned() else { - cx.quit(); + finish_quit(cx); return; }; + if state.updater.restart_path().is_some() && state.backend.is_retired_after_update() { + // A committed install retired old-build RPCs and deliberately retains + // owner.lock until process exit. Never probe or stop that host again. + state.quit_completed.set(true); + finish_quit(cx); + return; + } + if !may_finish_quit(cx) { + cancel_native_quit(); + show_update_about(cx); + return; + } // Once a soft close has committed its intentional detach, finishing that // frontend exit wins. It must never be reported as a completed host stop. if state.frontend_detached.get() { @@ -1950,6 +2173,12 @@ fn drain_and_quit(state: DesktopLifecycle, cx: &mut App) { cx.spawn(async move |cx| { match stop.await { Ok(Ok(())) => cx.update(|cx| { + if !may_finish_quit(cx) { + state.closing.set(false); + cancel_native_quit(); + show_update_about(cx); + return; + } state.quit_completed.set(true); #[cfg(target_os = "macos")] native_quit::complete(cx); @@ -1968,7 +2197,7 @@ fn drain_and_quit(state: DesktopLifecycle, cx: &mut App) { /// Flush presentation edits before either an explicit host stop or an /// intentional frontend-only detach. Neither path may lose pending settings. -async fn flush_frontend_state(backend: &Arc) -> Result<(), String> { +pub(crate) async fn flush_frontend_state(backend: &Arc) -> Result<(), String> { // A browser handoff holds the account writer while it waits. Cancellation // deliberately bypasses that writer in the host. tokio::time::timeout( @@ -2176,9 +2405,17 @@ impl Render for QuitPrompt { /// owner resident, even before a polling update reaches the GUI. pub(crate) fn close_window(window: &mut Window, cx: &mut App) { let Some(state) = cx.try_global::().cloned() else { - cx.quit(); + finish_quit(cx); return; }; + if state.updater.restart_path().is_some() { + quit_app(cx); + return; + } + if !may_finish_quit(cx) { + show_update_about(cx); + return; + } if state.closing.replace(true) { return; } @@ -2481,7 +2718,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 => {} } @@ -2560,7 +2797,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)) @@ -2568,13 +2805,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()); @@ -2611,6 +2869,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()); @@ -2681,10 +2944,13 @@ pub fn run() { } else { WindowBounds::Windowed(bounds) }; + let updater = crate::update::Controller::new(false); + let mut update_events = updater.subscribe(); let remote_enabled = Arc::new(std::sync::atomic::AtomicBool::new(false)); let store_resident = Arc::new(std::sync::atomic::AtomicBool::new(false)); cx.set_global(DesktopLifecycle { backend: backend.clone(), + updater: updater.clone(), remote_enabled: remote_enabled.clone(), store_resident: store_resident.clone(), can_background: false, @@ -2731,12 +2997,23 @@ pub fn run() { let expected = cx.try_global::().is_some_and(|state| { state.quit_completed.get() || state.frontend_detached.get() }); + let updater = cx + .try_global::() + .map(|state| state.updater.clone()); + if let Some(updater) = &updater { + updater.shutdown(); + } async move { if !expected { log::warn!( "Maple frontend terminated outside completed Quit or intentional detach" ); } + if let Some(updater) = updater { + // Best effort only; durable SQL shutdown was completed + // by the coordinator before this 200 ms GPUI callback. + updater.cleanup_on_exit().await; + } } }) .detach(); @@ -2752,6 +3029,7 @@ pub fn run() { let root_resident = store_resident.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 { @@ -2787,6 +3065,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)), }) @@ -2798,22 +3078,20 @@ pub fn run() { app.show_store_state(backend.store_state(), cx); }) .ok(); - // 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(); + // Poll/checkpoint only after current-store settings bootstrap. + 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 @@ -2890,6 +3168,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() { @@ -3408,6 +3691,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/desktop/maintenance_tests.rs b/apps/maple-agent/app/src/desktop/maintenance_tests.rs index be9481018..ba1e3d886 100644 --- a/apps/maple-agent/app/src/desktop/maintenance_tests.rs +++ b/apps/maple-agent/app/src/desktop/maintenance_tests.rs @@ -20,6 +20,7 @@ enum HostCommand { Finish(Result, StoreState, &'static str), State(StoreState), ReleaseFlush, + AutomaticDownloads(bool), } struct FixtureHost { @@ -128,6 +129,7 @@ impl FixtureHost { let mut pending = None; let mut running = runtime_survives; let mut pending_flush = None; + let mut automatic_downloads = true; loop { tokio::select! { command = control.recv() => { @@ -140,6 +142,7 @@ impl FixtureHost { if protocol::write(&mut output, &protocol::Message::Response { id, result }).await.is_err() { break; } } HostCommand::State(next) => state = next, + HostCommand::AutomaticDownloads(enabled) => automatic_downloads = enabled, HostCommand::ReleaseFlush => { let id = pending_flush.take().expect("a deferred settings flush"); if protocol::write(&mut output, &protocol::Message::Response { id, result: Ok(Value::Null) }).await.is_err() { break; } @@ -165,7 +168,13 @@ impl FixtureHost { let result = match request.method.as_str() { "host_status" => Ok(json!({"acp_clients":1,"running_tasks":0,"remote_controllers":0,"background_tasks":0})), "store_status" => Ok(json!({"state":state,"mode":mode,"store_id":store_id,"connection_rebind_required":false,"password_settings_available":true})), - "get_ui_settings" => Ok(serde_json::to_value(crate::settings::AppSettings::default()).unwrap()), + "get_ui_settings" => { + let settings = crate::settings::AppSettings { + automatic_update_downloads: automatic_downloads, + ..Default::default() + }; + Ok(serde_json::to_value(settings).unwrap()) + }, "dictionary" => Ok(json!({"words":[],"next":null})), "navigation_bootstrap" => Ok(serde_json::to_value(crate::backend::NavigationBootstrap { runtime_status: runtime_status(running), project_root: runtime_status(running).project_root, @@ -713,6 +722,7 @@ fn rejection_before_runtime_shutdown_clears_barrier_without_restarting( fn close_lifecycle(host: &FixtureHost) -> DesktopLifecycle { DesktopLifecycle { backend: host.backend.clone(), + updater: crate::update::Controller::new(false), remote_enabled: Arc::new(std::sync::atomic::AtomicBool::new(false)), store_resident: Arc::new(std::sync::atomic::AtomicBool::new(false)), can_background: false, @@ -723,6 +733,119 @@ fn close_lifecycle(host: &FixtureHost) -> DesktopLifecycle { } } +#[gpui::test] +fn updater_bootstrap_waits_for_open_store_and_uses_saved_download_preference( + cx: &mut gpui::TestAppContext, +) { + let settings = crate::settings::SETTINGS_IO_LOCK.lock(); + let host = FixtureHost::new(); + let app = shell(&host, cx, false, &settings); + let updater = crate::update::Controller::new(false); + updater.set_auto_download(true); + app.update(cx, |app, _| app.updater = updater.clone()); + let cx = window(&app, cx); + host.commands + .send(HostCommand::State(StoreState::Locked)) + .unwrap(); + wait(cx, "locked store gate", |cx| { + app.update(cx, |app, _| matches!(app.screen, Screen::StoreGate(_))) + }); + assert_eq!(updater.startup_policy_for_test(), (false, false)); + host.commands + .send(HostCommand::AutomaticDownloads(false)) + .unwrap(); + host.commands + .send(HostCommand::State(StoreState::Open)) + .unwrap(); + wait(cx, "verified settings bootstrap", |cx| { + app.update(cx, |app, _| app.store_ready) && updater.startup_policy_for_test().0 + }); + assert!(!updater.startup_policy_for_test().1); + assert!(host.count("get_ui_settings") > 0); + updater.shutdown(); + drop(host); + settle(cx); +} + +#[gpui::test] +fn pending_quit_cannot_finish_an_update_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); + cx.update(|cx| { + cx.set_global(DesktopLifecycle { + backend, + updater: updater.clone(), + remote_enabled: Arc::new(std::sync::atomic::AtomicBool::new(true)), + store_resident: Arc::new(std::sync::atomic::AtomicBool::new(false)), + can_background: true, + closing: Default::default(), + quit_completed: Default::default(), + quit_requested: Default::default(), + frontend_detached: Default::default(), + }); + updater.set_test_state(crate::update::State::Ready); + assert!(may_finish_quit(cx)); + updater.set_test_state(crate::update::State::Installing); + assert!(!may_finish_quit(cx)); + assert!(!cx.global::().quit_completed.get()); + updater.set_test_state(crate::update::State::Error); + assert!(may_finish_quit(cx)); + }); +} + +#[gpui::test] +fn committed_restart_retry_quit_and_close_do_not_contact_the_retired_host( + cx: &mut gpui::TestAppContext, +) { + let settings = crate::settings::SETTINGS_IO_LOCK.lock(); + let host = FixtureHost::with_close_outcome(false, true, "plain", false); + let app = shell(&host, cx, false, &settings); + let lifecycle = close_lifecycle(&host); + let path = PathBuf::from("/Applications/Maple Agent.app"); + lifecycle.backend.retire_after_update(); + lifecycle.updater.set_test_installed(path.clone()); + app.update(cx, |app, cx| { + app.updater = lifecycle.updater.clone(); + cx.set_global(lifecycle.clone()); + }); + for _ in 0..2 { + let mut restarted = cx.expect_restart(); + cx.update(|cx| restart_installed_update(path.clone(), cx)); + assert_eq!(restarted.try_recv().unwrap(), Some(Some(path.clone()))); + } + let cx = window(&app, cx); + host.requests.lock().unwrap().clear(); + cx.update(close_window); + cx.update(|_, cx| quit_app(cx)); + settle(cx); + assert!(lifecycle.quit_completed.get()); + assert!(!lifecycle.closing.get()); + assert!(!lifecycle.frontend_detached.get()); + for method in [ + "store_status", + "host_status", + "flush_ui_settings", + "cancel_oauth", + "detach", + ] { + assert_eq!( + host.count(method), + 0, + "terminal update must bypass {method}" + ); + } + assert_eq!( + lifecycle.updater.snapshot().state, + crate::update::State::RestartRequired + ); + #[cfg(target_os = "macos")] + native_quit::reset_frontend_detach_for_test(); + drop(host); + settle(cx); +} + #[gpui::test] fn plain_window_close_detaches_without_stopping_other_clients(cx: &mut gpui::TestAppContext) { let settings = crate::settings::SETTINGS_IO_LOCK.lock(); diff --git a/apps/maple-agent/app/src/desktop/native_quit.rs b/apps/maple-agent/app/src/desktop/native_quit.rs index 34daed218..6970c3aa6 100644 --- a/apps/maple-agent/app/src/desktop/native_quit.rs +++ b/apps/maple-agent/app/src/desktop/native_quit.rs @@ -111,6 +111,20 @@ pub(super) fn complete(cx: &mut gpui::App) { allow_termination(cx); } +/// Called only after an update commit retained owner.lock for process exit. +/// Allow GPUI's restart termination, without quitting before its helper starts. +pub(super) fn allow_update_restart() { + TERMINATION_ALLOWED.with(|allowed| allowed.set(true)); +} + +/// GPUI's helper has now been requested. Complete an already deferred native +/// Quit, while leaving the retry UI alive if no Quit was pending and spawn fails. +pub(super) fn finish_update_restart() { + if PENDING.with(Cell::get) { + defer_reply(true); + } +} + pub(super) fn is_pending() -> bool { PENDING.with(Cell::get) } diff --git a/apps/maple-agent/app/src/host.rs b/apps/maple-agent/app/src/host.rs index 95d98cefe..edf1049ec 100644 --- a/apps/maple-agent/app/src/host.rs +++ b/apps/maple-agent/app/src/host.rs @@ -615,6 +615,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 _previous_owner = endpoint.previous_owner_guard()?; @@ -2166,6 +2177,325 @@ pub fn stop(api_url: &str) -> Result<(), String> { }) } +/// 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`. 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 acquire_update_fence(api_url: &str) -> Result { + let endpoint = ipc::Endpoint::new(api_url)?; + 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 acquire_update_fence_endpoint( + endpoint: &ipc::Endpoint, + timeout: Duration, + stop: impl FnOnce() -> Result<(), String>, +) -> 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.ownership_lock()?; + wait_update_lock(&ownership, timeout, "Maple host is still shutting down")?; + 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")] +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; + + #[tokio::test] + async fn durable_store_shutdown_failure_prevents_update_and_preserves_owner() { + let root = tempfile::tempdir().unwrap(); + let backend = Arc::new(LocalBackend::isolated_for_test(root.path())); + let store = backend.store().unwrap().clone(); + store + .set_app_setting("update-sentinel", &serde_json::json!(true), 1) + .unwrap(); + let endpoint = ipc::Endpoint::for_test(store.paths().root().to_path_buf()); + let ownership = endpoint.ownership_lock().unwrap(); + ownership.lock().unwrap(); + let ownership = Arc::new(std::sync::Mutex::new(Some(ownership))); + let owner = Arc::new(OwnerContext::new(Some(&store))); + let receipts = Arc::new(receipts::Receipts::new(store.clone())); + let (events, _) = broadcast::channel(16); + let remote = Arc::new(crate::remote::manager::Manager::new( + backend.clone(), + events.clone(), + receipts, + )); + // Exercise a real late durable-close failure through the existing SQL + // fixture boundary; no raw SQLite handle or production test hook. + let spills = maple_agent::store::spill::SpillRoot::new(store.paths()).unwrap(); + spills + .task_dir("00000000-0000-0000-0000-000000000001") + .unwrap(); + std::fs::remove_dir_all(spills.path()).unwrap(); + std::fs::write(spills.path(), b"invalid spill fixture").unwrap(); + let replaced = Arc::new(AtomicBool::new(false)); + let attempt = || { + let (endpoint, backend, owner, remote, events, ownership, replaced) = ( + endpoint.clone(), + backend.clone(), + owner.clone(), + remote.clone(), + events.clone(), + ownership.clone(), + replaced.clone(), + ); + let runtime = tokio::runtime::Handle::current(); + tokio::task::spawn_blocking(move || { + with_update_fence_endpoint( + &endpoint, + Duration::from_secs(2), + || { + runtime.block_on(owner.shutdown(&backend, &remote, &events))?; + drop(ownership.lock().unwrap().take()); + Ok(()) + }, + || { + assert!(matches!( + endpoint.ownership_lock().unwrap().try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + // The previous inode is no longer the authoritative fence. + endpoint.lock("host.lock").unwrap().try_lock().unwrap(); + replaced.store(true, Ordering::SeqCst); + Ok(()) + }, + ) + }) + }; + assert!(attempt().await.unwrap().is_err()); + assert!(!replaced.load(Ordering::SeqCst)); + assert!(matches!( + endpoint.ownership_lock().unwrap().try_lock(), + Err(std::fs::TryLockError::WouldBlock) + )); + assert_eq!(store.state(), StoreState::Open); + assert_eq!( + store.app_setting("update-sentinel").unwrap(), + Some(serde_json::json!(true)) + ); + endpoint.lock("startup.lock").unwrap().try_lock().unwrap(); + + std::fs::remove_file(spills.path()).unwrap(); + attempt().await.unwrap().unwrap(); + assert!(replaced.load(Ordering::SeqCst)); + store.initialize_plain().unwrap(); + assert_eq!( + store.app_setting("update-sentinel").unwrap(), + Some(serde_json::json!(true)) + ); + store.close_for_shutdown().unwrap(); + } + + #[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 lock in [ + endpoint.lock("startup.lock").unwrap(), + endpoint.ownership_lock().unwrap(), + ] { + assert!(matches!( + lock.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.ownership_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(); + let endpoint = ipc::Endpoint::for_test(directory.path().to_path_buf()); + let old_host = endpoint.ownership_lock().unwrap(); + old_host.lock().unwrap(); + let queued_start = endpoint.lock("startup.lock").unwrap(); + let direct_start = endpoint.ownership_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.ownership_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.ownership_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 241463e28..128cfe4d9 100644 --- a/apps/maple-agent/app/src/profile.rs +++ b/apps/maple-agent/app/src/profile.rs @@ -33,6 +33,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 } @@ -92,9 +97,46 @@ 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); } + #[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/app/src/settings.rs b/apps/maple-agent/app/src/settings.rs index 5152fe9e5..15069bc6e 100644 --- a/apps/maple-agent/app/src/settings.rs +++ b/apps/maple-agent/app/src/settings.rs @@ -51,6 +51,9 @@ pub struct AppSettings { /// while the window is not focused. #[serde(default = "default_desktop_notifications")] pub desktop_notifications: bool, + /// Background downloads are optional; installation always needs confirmation. + #[serde(default = "default_automatic_update_downloads")] + pub automatic_update_downloads: bool, /// Explicit permission to send task titles and questions to the operating /// system's notification history while the local store uses a password. #[serde(default)] @@ -205,6 +208,10 @@ fn default_desktop_notifications() -> bool { true } +fn default_automatic_update_downloads() -> bool { + true +} + fn default_store_password_mode() -> bool { true } @@ -227,6 +234,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(), password_notification_details: false, store_password_mode: default_store_password_mode(), reduce_motion: false, @@ -904,6 +912,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"); @@ -926,6 +945,7 @@ mod tests { install_test_store(store.clone()); update_settings_in_background(|settings| settings.tts_voice = "neutral_male".into()); update_settings_in_background(|settings| settings.tts_speed = 1.5); + update_settings_in_background(|settings| settings.automatic_update_downloads = false); update_settings_in_background(|settings| { settings .shortcut_overrides @@ -935,6 +955,7 @@ mod tests { let reloaded = load_host_settings(&store, None).unwrap(); assert_eq!(reloaded.tts_voice, "neutral_male"); assert!((reloaded.tts_speed - 1.5).abs() < 0.01); + assert!(!reloaded.automatic_update_downloads); assert_eq!( reloaded.shortcut_overrides.get("chat.new_task"), Some(&None) diff --git a/apps/maple-agent/app/src/tray.rs b/apps/maple-agent/app/src/tray.rs index d0d3e0348..64d9b0e12 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 1937173bc..d65e0e1aa 100644 --- a/apps/maple-agent/app/src/ui/chat/mod.rs +++ b/apps/maple-agent/app/src/ui/chat/mod.rs @@ -687,7 +687,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. @@ -1329,7 +1329,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, @@ -3563,15 +3563,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() @@ -3586,42 +3590,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"), ), ) } @@ -6160,6 +6157,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 81a96b839..62518d1a1 100644 --- a/apps/maple-agent/app/src/ui/settings.rs +++ b/apps/maple-agent/app/src/ui/settings.rs @@ -138,6 +138,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, @@ -251,11 +252,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(crate) fn settings_snapshot(&self) -> AppSettings { self.settings.clone() } @@ -393,6 +406,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), @@ -1897,14 +1911,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( widgets::secondary_button("third-party-notices") .on_click(cx.listener(|this, _event, _window, cx| { diff --git a/apps/maple-agent/app/src/update.rs b/apps/maple-agent/app/src/update.rs index 1c9f26f3c..5437cb8a3 100644 --- a/apps/maple-agent/app/src/update.rs +++ b/apps/maple-agent/app/src/update.rs @@ -1,450 +1,1094 @@ -//! 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::path::{Path, PathBuf}; +use std::sync::{ + Arc, OnceLock, + atomic::{AtomicBool, Ordering}, +}; 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 maple_agent_updater::{ + CheckOutcome, Config, PreparedUpdate, Revalidation, Target, Updater, VerifiedRelease, }; +use tokio::sync::{Mutex, OnceCell, watch}; +use tokio_util::sync::CancellationToken; -#[cfg(test)] -const PROD_CHANNEL: ReleaseChannel = ReleaseChannel { - tag_prefix: "maple-agent-v", - prerelease: false, -}; -#[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; - -#[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, + RestartRequired, + 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, + Relaunch, + 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: AtomicBool, +} +impl Service { + fn manual_only(&self) -> bool { + self.manual_only.load(Ordering::Acquire) + } } -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() - .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() - ); - return None; +#[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, + } } - 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; +} +impl From for InstallationFailure { + fn from(message: String) -> Self { + Self { + message, + manual: false, + recover_host: false, } - bytes.extend_from_slice(&chunk); } - serde_json::from_slice(&bytes).ok() } +impl From<&str> for InstallationFailure { + fn from(message: &str) -> Self { + message.to_owned().into() + } +} +#[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, + started: 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 +/// check/download/install; repeated clicks cannot launch competing transactions. +#[derive(Clone)] +pub struct Controller(Arc); -/// 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; - } - for release in &releases { - let Some(version) = agent_version(channel, release) else { - continue; +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), + started: AtomicBool::new(false), + cancel: CancellationToken::new(), + installed: OnceLock::new(), + })) + } + pub fn snapshot(&self) -> Snapshot { + self.0.snapshots.borrow().clone() + } + #[cfg(test)] + pub(crate) fn startup_policy_for_test(&self) -> (bool, bool) { + ( + self.0.started.load(Ordering::Acquire), + self.0.automatic.load(Ordering::Acquire), + ) + } + #[cfg(test)] + 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) { + 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) { + if self.restart_path().is_some() && snapshot.state != State::RestartRequired { + return; + } + 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 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; + 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.0.started.swap(true, Ordering::AcqRel) { + return; + } + 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 { + if self.restart_path().is_some() { + return; + } + 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() || self.restart_path().is_some() { + 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; + } + let previous = self.snapshot(); + 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.recheck_failed(previous), + } + } + 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) }; - if version.cmp_precedence(current).is_gt() - && newest.as_ref().is_none_or(|best| &version > best) - { - newest = Some(version); + 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.installation_failed(service, &release, error).await, } + } 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; } - if releases.len() < RELEASES_PER_PAGE { - return newest; + } + 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; + 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(InstallationFailure::from)? + } + }; + 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.into()); + } + } + } + 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())??; + let mut releases = self.0.releases.lock().await; + releases.installation = Some(installation); + 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); } } - 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; + 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"), + }, + ); + 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); + } + pub async fn download(&self) { + if self.0.cancel.is_cancelled() || self.restart_path().is_some() { + 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; + } + } + 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.installation_failed(service, &release, error).await, + } + } + Err(_) => { + self.fail("Could not download or verify the update. Retry when you are ready.") + } + } + } + /// Apply on the UI thread accepting the current settings generation. A + /// delayed runtime task must never overwrite a newer saved opt-out. + pub fn set_auto_download(&self, enabled: bool) { + self.0.automatic.store(enabled, Ordering::Relaxed); + } + pub async fn download_if_automatic(&self) { + if self.0.automatic.load(Ordering::Relaxed) && self.snapshot().state == State::Available { + self.download().await; + } + } + 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, + 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(backend).await; + if let Some(path) = self.restart_path() { + return Ok(Some(path)); + } + if let Err(error) = &result { + 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.map_err(|error| error.message) } - 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; + 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 + .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(InstallationFailure::from)?, + }; + 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.into()); + } + preparation + } + None => maple_agent_updater::installer::prepare(&staging) + .map_err(InstallationFailure::from)?, + }; + Ok::<_, InstallationFailure>((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); + } + } + // Commit every pending frontend patch before asking the host to drain. + // Failed saves leave the live store and installation untouched. + if let Err(error) = crate::desktop::flush_frontend_state(&backend).await { + discard_preparation(preparation).await; + return Err(error.into()); + } + 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; + // 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(|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()); + drop(host_fence); + if result.is_err() { + let _ = maple_agent_updater::installer::discard_preparation(&preparation); + } + 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(InstallationFailure { + message: format!( + "{}. Quit and reopen Maple to restore its host.", + error.message + ), + ..error + }); + } + Err(InstallationFailure { + message: format!( + "{}. Quit and reopen Maple before continuing work.", + error.message + ), + ..error + }) + } + } } - Some(version) } -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 +async fn discard_preparation(preparation: maple_agent_updater::installer::InstallPreparation) { + discard_preparation_with_lock(preparation, None).await; +} + +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; +} + +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 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: update_state_dir(), + }; + Ok(Service { + updater: Arc::new( + Updater::new(config).map_err(|_| "Invalid packaged update configuration")?, ), - version: version.to_string(), + installed_path, + manual_only: AtomicBool::new(manual_only), }) } +fn update_state_dir() -> PathBuf { + // Signed discovery/anti-rollback state survives local-store reset/repair. + crate::backend::profile_data_root().join("updates") +} + +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") +} + #[cfg(test)] mod tests { use super::*; - 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, - }) - } - - 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 + #[test] + fn discovery_state_stays_outside_the_resettable_sql_store() { + let profile = crate::backend::profile_data_root(); + let store = maple_agent::store::StorePaths::new(&profile); + assert_eq!(update_state_dir(), profile.join("updates")); + assert!(!update_state_dir().starts_with(store.root())); } #[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"); + async fn deferred_store_bootstrap_cannot_overwrite_a_newer_opt_out() { + let controller = Controller::new(false); + controller.set_test_state(State::Disabled); + controller.set_auto_download(true); + let older_bootstrap = controller.clone().run(); + controller.set_auto_download(false); + older_bootstrap.await; + assert_eq!(controller.startup_policy_for_test(), (true, false)); + controller.clone().run().await; + assert_eq!(controller.startup_policy_for_test(), (true, false)); + } + #[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()); + #[test] + 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()); + 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.recheck_failed(downloading); + let snapshot = controller.snapshot(); + 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); } - #[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"); + 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()); } - - #[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()); + #[tokio::test] + 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(Arc::new( + crate::backend::AgentBackend::for_test( + "http://127.0.0.1:9".into(), + String::new() + ) + .unwrap() + )) + .await + .unwrap() + .is_none() + ); + assert!(controller.0.service.get().is_none()); + assert_eq!(controller.snapshot().state, State::Idle); } - - #[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 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()); } - #[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(); + 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!( - info.url, - "https://github.com/MaplePrivacyLabs/Maple/releases/tag/maple-agent-dev-v1.3.0" + controller.install(backend).await.unwrap(), + Some(path.clone()) ); - 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_eq!(controller.restart_path(), Some(path)); + assert_eq!(controller.snapshot().state, State::RestartRequired); + assert!(controller.0.service.get().is_none()); } - - #[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") + // 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); + 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!(configured_repo(false, Some("https://evil.example/releases".into())).is_none()); + assert!(snapshot.label.contains("install manually")); + assert!(controller.restart_path().is_none()); } } 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/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/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..25134160d --- /dev/null +++ b/apps/maple-agent/crates/maple-agent-updater/src/installer.rs @@ -0,0 +1,2066 @@ +//! 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::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}; + +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), +} + +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 +/// `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, +} + +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 { + #[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", + ))?; + preflight_installation(app)?; + Ok(app.to_owned()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = executable; + Err(InstallError::NotifyOnly( + "automatic installation is not enabled on this platform", + )) + } +} + +/// 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 file = open_install_lock(installed)?; + file.try_lock_exclusive().map_err(|e| { + if e.kind() == io::ErrorKind::WouldBlock { + InstallError::Busy + } else { + 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 { + preflight_installation(&update.installed_path)?; + prepare_inner(update, Verifier::Native) +} + +pub fn revalidate(preparation: &InstallPreparation, update: &PreparedUpdate) -> Result<()> { + 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. +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 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| { + 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 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 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(); + 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/docs/desktop-builds.md b/apps/maple-agent/docs/desktop-builds.md index 9e1250c86..7f49a89da 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 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 @@ -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 @@ -116,21 +129,33 @@ 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 existing `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. +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 -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 +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, +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..c37f0ea97 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,30 @@ 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 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 +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..5eed6aa1d --- /dev/null +++ b/apps/maple-agent/docs/updates.md @@ -0,0 +1,186 @@ +# 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 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 +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. + +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. + +## 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. + +Discovery and anti-rollback state live in the profile's `updates/` directory, +outside the resettable `store-v1/` SQLite data. The startup checkpoint waits for +the current Open store and its saved UI settings to load; locked, damaged or +loading stores retain the previous application. Automatic downloads start with +the saved preference, and store reattachment does not create another polling loop. +Restart flushes pending frontend settings before the host's coordinated durable +shutdown. Installation holds both startup admission and the store's authoritative +owner lock through replacement and the old process's exit. A committed restart +or terminal Quit never sends another old-build request to that stopped host. + +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. 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; + 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 + Worker deployment configuration so deploy tokens do not need route-write + authority. Configure HTTP-to-HTTPS handling in the zone. +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 + 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. +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. + +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/flake.nix b/apps/maple-agent/flake.nix index 2cfc41365..99c3cc584 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 = ../..; diff --git a/apps/maple-agent/release-profiles.json b/apps/maple-agent/release-profiles.json index cb0be6b74..67e6ea661 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,11 @@ "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": [ + "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 efd189f16..74dc0e3fc 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,14 @@ 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") + ); + 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"); @@ -238,5 +294,43 @@ 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()); + 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()], + 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/macos-release-app.sh b/apps/maple-agent/scripts/macos-release-app.sh index b4c461c19..d5d4ec694 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 @@ -247,7 +256,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/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 a3f6f0a40..bed773cb5 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 @@ -25,6 +26,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): @@ -351,6 +398,43 @@ 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_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() + 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") @@ -586,7 +670,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 }} @@ -635,6 +727,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/docs/app-signing-environments.md b/docs/app-signing-environments.md index ea445d5866..b8e5c6ea7 100644 --- a/docs/app-signing-environments.md +++ b/docs/app-signing-environments.md @@ -6,7 +6,7 @@ Branch protection on `master` alone does not protect repository-level secrets. | Environment | Credentials | Jobs | | --- | --- | --- | -| `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 | @@ -26,16 +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` also uses the existing `desktop-signing` environment -for its trusted `master` macOS Dev/Prod jobs. 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 40314f047..80fe4fea0 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -395,8 +395,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"], { @@ -406,6 +410,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]) diff --git a/scripts/ci/test_signing_workflows.py b/scripts/ci/test_signing_workflows.py index a3520e536..0d5914163 100644 --- a/scripts/ci/test_signing_workflows.py +++ b/scripts/ci/test_signing_workflows.py @@ -61,6 +61,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",