From c7bff5654038724612f18c3d2064e35ecc219def Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:36:37 +0000 Subject: [PATCH 1/4] Integrate Agent hosted handoff and isolated Research Dev desktop --- .github/workflows/agent-ci.yml | 2 +- .github/workflows/agent-desktop-build.yml | 2 +- .github/workflows/desktop-pr-build.yml | 40 +- .github/workflows/frontend-tests.yml | 2 +- .github/workflows/repository-checks.yml | 2 +- .github/workflows/rust-tests.yml | 2 +- .github/workflows/web-build.yml | 2 +- apps/maple-agent/CLAUDE.md | 8 + apps/maple-agent/Cargo.lock | 3 + apps/maple-agent/README.md | 4 + apps/maple-agent/app/Cargo.toml | 5 + apps/maple-agent/app/build.rs | 1 + apps/maple-agent/app/src/backend.rs | 247 ++++++++++- apps/maple-agent/app/src/hosted_oauth.rs | 399 ++++++++++++++++++ apps/maple-agent/app/src/main.rs | 1 + apps/maple-agent/app/src/profile.rs | 2 + apps/maple-agent/app/src/ui/login.rs | 197 ++++++++- apps/maple-agent/docs/release-profiles.md | 15 +- apps/maple-agent/release-profiles.json | 2 + apps/maple-agent/release_profile.rs | 26 ++ apps/maple-agent/scripts/release-info.py | 6 +- apps/maple-auth/AGENTS.md | 6 +- apps/maple-auth/README.md | 31 ++ apps/maple-auth/src/auth-site/HostedStart.tsx | 19 +- .../src/auth-site/agentReturn.test.ts | 38 ++ .../auth-site/fixtures/AgentReturn.case.tsx | 337 +++++++++++++++ .../src/auth-site/fixtures/AuthSite.case.tsx | 32 ++ apps/maple-auth/src/auth-site/route.test.ts | 104 +++++ apps/maple-auth/src/auth-site/route.ts | 61 ++- apps/maple-auth/src/auth-site/ui.test.ts | 2 +- .../HostedNativeSignInConfirmation.tsx | 70 +-- .../services/desktopOAuthTransport.test.ts | 179 +++++++- .../src/services/desktopOAuthTransport.ts | 136 ++++-- apps/maple-research/README.md | 5 + .../docs/research-dev-desktop.md | 84 ++++ .../frontend/src-tauri/Cargo.toml | 1 + .../frontend/src-tauri/build.rs | 53 +++ .../src-tauri/desktop-dev-profile.json | 16 + .../src-tauri/src/desktop_app_profile.rs | 81 ++++ .../frontend/src-tauri/src/lib.rs | 46 +- .../src-tauri/tauri.desktop-dev.conf.json | 136 ++++++ .../src/components/DeepLinkHandler.test.tsx | 47 +++ .../src/components/DeepLinkHandler.tsx | 24 +- .../settings/UpdateSettings.test.tsx | 20 + .../components/settings/UpdateSettings.tsx | 2 + .../services/desktopOAuthTransport.test.ts | 8 +- .../frontend/src/services/updateService.ts | 1 + .../maple-research/frontend/src/vite-env.d.ts | 1 + flake.nix | 1 + scripts/ci/change_detection.py | 2 + scripts/ci/research-dev-desktop.sh | 33 ++ scripts/ci/research-dev-profile.py | 65 +++ scripts/ci/test_agent_workflows.py | 4 +- scripts/ci/test_change_detection.py | 8 + scripts/ci/test_research_dev_profile.py | 71 ++++ scripts/ci/test_select_xcode.py | 1 + 56 files changed, 2547 insertions(+), 146 deletions(-) create mode 100644 apps/maple-agent/app/src/hosted_oauth.rs create mode 100644 apps/maple-auth/src/auth-site/agentReturn.test.ts create mode 100644 apps/maple-auth/src/auth-site/fixtures/AgentReturn.case.tsx create mode 100644 apps/maple-research/docs/research-dev-desktop.md create mode 100644 apps/maple-research/frontend/src-tauri/desktop-dev-profile.json create mode 100644 apps/maple-research/frontend/src-tauri/src/desktop_app_profile.rs create mode 100644 apps/maple-research/frontend/src-tauri/tauri.desktop-dev.conf.json create mode 100755 scripts/ci/research-dev-desktop.sh create mode 100644 scripts/ci/research-dev-profile.py create mode 100644 scripts/ci/test_research_dev_profile.py diff --git a/.github/workflows/agent-ci.yml b/.github/workflows/agent-ci.yml index eadbd7ba3..2692a57d3 100644 --- a/.github/workflows/agent-ci.yml +++ b/.github/workflows/agent-ci.yml @@ -4,7 +4,7 @@ on: push: branches: [master] pull_request: - branches: [master] + # Run unprivileged validation for stacked PRs as well as master targets. workflow_dispatch: # PR code, including forks, executes only on fresh hosted runners with a diff --git a/.github/workflows/agent-desktop-build.yml b/.github/workflows/agent-desktop-build.yml index af23597f5..92970d59f 100644 --- a/.github/workflows/agent-desktop-build.yml +++ b/.github/workflows/agent-desktop-build.yml @@ -4,7 +4,7 @@ on: push: branches: [master] pull_request: - branches: [master] + # Run unprivileged validation for stacked PRs as well as master targets. workflow_dispatch: # Signing is confined to protected master jobs. PR packaging runs with a diff --git a/.github/workflows/desktop-pr-build.yml b/.github/workflows/desktop-pr-build.yml index 091db34b1..66d6f52bb 100644 --- a/.github/workflows/desktop-pr-build.yml +++ b/.github/workflows/desktop-pr-build.yml @@ -5,7 +5,7 @@ permissions: on: pull_request: - branches: [master] + # Run unprivileged validation for stacked PRs as well as master targets. jobs: changes: @@ -55,6 +55,44 @@ jobs: apps/maple-research/frontend/src-tauri/target/reproducibility/desktop-pr-macos-*.sha256 retention-days: 5 + build-research-dev-macos: + needs: changes + if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.macos != 'false') }} + runs-on: macos-26-xlarge + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 + with: + persist-credentials: false + + - name: Select pinned Xcode + run: python3 scripts/ci/select-xcode.py + + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@ef8a148080ab6020fd15196c2084a2eea5ff2d25 # was v22 + with: + github-token: "" + + - name: Cache sccache + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # was v4 + with: + path: ~/Library/Caches/Mozilla.sccache + key: ${{ runner.os }}-sccache-research-dev-${{ hashFiles('apps/maple-research/frontend/src-tauri/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-sccache-research-dev- + + - name: Build isolated Research Dev app (unsigned) + run: nix develop --no-update-lock-file .#ci -c ./scripts/ci/research-dev-desktop.sh + + - name: Upload Research Dev PR package and identity proof + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # was v4 + with: + name: maple-research-dev-macos-pr + path: | + apps/maple-research/frontend/src-tauri/target/research-dev/maple-research-dev-macos.tar.gz + apps/maple-research/frontend/src-tauri/target/research-dev/build-profile.json + if-no-files-found: error + retention-days: 5 + build-linux: needs: changes if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.linux != 'false') }} diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml index a1968ddc3..50bdbc448 100644 --- a/.github/workflows/frontend-tests.yml +++ b/.github/workflows/frontend-tests.yml @@ -17,7 +17,7 @@ on: - "flake.nix" - "flake.lock" pull_request: - branches: [ master ] + # Run unprivileged validation for stacked PRs as well as master targets. paths: - ".github/workflows/frontend-tests.yml" - "apps/maple-research/frontend/**" diff --git a/.github/workflows/repository-checks.yml b/.github/workflows/repository-checks.yml index 6a78ab9b1..1333cd91e 100644 --- a/.github/workflows/repository-checks.yml +++ b/.github/workflows/repository-checks.yml @@ -12,7 +12,7 @@ on: - "flake.nix" - "flake.lock" pull_request: - branches: [master] + # Run unprivileged validation for stacked PRs as well as master targets. paths: - ".github/workflows/**" - "scripts/ci/**" diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml index 4ca97588d..5048c2dd4 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/rust-tests.yml @@ -23,7 +23,7 @@ on: - "flake.nix" - "flake.lock" pull_request: - branches: [master] + # Run unprivileged validation for stacked PRs as well as master targets. paths: - ".github/workflows/rust-tests.yml" - "apps/maple-research/frontend/src-tauri/**" diff --git a/.github/workflows/web-build.yml b/.github/workflows/web-build.yml index 505fed7b0..5bca81fc2 100644 --- a/.github/workflows/web-build.yml +++ b/.github/workflows/web-build.yml @@ -17,7 +17,7 @@ on: - "flake.nix" - "flake.lock" pull_request: - branches: [ master ] + # Run unprivileged validation for stacked PRs as well as master targets. paths: - ".github/workflows/web-build.yml" - "apps/maple-research/frontend/**" diff --git a/apps/maple-agent/CLAUDE.md b/apps/maple-agent/CLAUDE.md index 5a381d0a8..dc95b0125 100644 --- a/apps/maple-agent/CLAUDE.md +++ b/apps/maple-agent/CLAUDE.md @@ -8,6 +8,14 @@ GPUI desktop app for Maple. Workspace crates: `app` (package `maple-agent-app`, `crates/maple-agent`, `crates/maple-billing`. See `README.md` for the layout, prerequisites, and command line modes. +Packaged Dev OAuth uses the compiled Auth origin and a one-attempt loopback +listener in `app/src/hosted_oauth.rs`; Prod and unpackaged builds retain the +provider callback paste flow. Keep the listener bound to `127.0.0.1` through +completion, close it on cancellation/deadline/view disposal, and redeem the +original SDK prepared handle only once. Publish through the existing backend +account authority; never log callback queries or grants. Update profile metadata +and release verification together when changing the compiled Auth destination. + ## Build, test, run `just` lists the recipes. `just ci` runs the same checks as CI (format, diff --git a/apps/maple-agent/Cargo.lock b/apps/maple-agent/Cargo.lock index 184225b6b..d98fa4764 100644 --- a/apps/maple-agent/Cargo.lock +++ b/apps/maple-agent/Cargo.lock @@ -5776,6 +5776,7 @@ dependencies = [ "env_logger", "gpui", "gpui_platform", + "httparse", "image 0.25.10", "log", "maple-agent", @@ -5786,6 +5787,7 @@ dependencies = [ "parking_lot", "percent-encoding", "pulldown-cmark", + "rand 0.8.7", "reqwest 0.13.4", "rodio", "rpassword", @@ -5800,6 +5802,7 @@ dependencies = [ "uuid", "wayland-client", "webbrowser", + "windows-sys 0.61.2", ] [[package]] diff --git a/apps/maple-agent/README.md b/apps/maple-agent/README.md index 937235e9e..f5adf42ba 100644 --- a/apps/maple-agent/README.md +++ b/apps/maple-agent/README.md @@ -53,6 +53,10 @@ has an independent dependency graph. ## Features - Sign in with email and password, or with GitHub, Google, or Apple OAuth. + Packaged Dev uses hosted browser sign-in and an automatic loopback return; + Prod and unpackaged builds retain the callback URL paste flow. Hosted Dev + requires the matching Auth host and provider/backend callback configuration; + building the client does not deploy those services. The session persists in `auth.json` (mode 0600) so the next launch and the `acp` mode skip sign-in. The window opens while the saved session is checked, and a check that cannot reach the server keeps the credentials diff --git a/apps/maple-agent/app/Cargo.toml b/apps/maple-agent/app/Cargo.toml index 448af2fb9..50fd76ad8 100644 --- a/apps/maple-agent/app/Cargo.toml +++ b/apps/maple-agent/app/Cargo.toml @@ -45,6 +45,8 @@ gpui = { version = "0.2.2", git = "https://github.com/zed-industries/zed", rev = gpui-platform = { package = "gpui_platform", version = "0.1.0", git = "https://github.com/zed-industries/zed", rev = "cc053a4a6fa2fd0e8793201ed9099466af1be0b1", features = ["font-kit", "wayland", "x11"], optional = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"], optional = true } base64 = "0.22" +httparse = "1" +rand = "0.8.6" tokio = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -76,6 +78,9 @@ tower-http = { version = "0.6", features = ["cors"], optional = true } [target.'cfg(target_os = "linux")'.dependencies] wayland-client = { version = "0.31", optional = true } +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock"] } + [dev-dependencies] parking_lot = "0.12" # The gpui test harness. Keep this out of [dependencies]: with test-support on, diff --git a/apps/maple-agent/app/build.rs b/apps/maple-agent/app/build.rs index 3c3a02a81..0bb1063f7 100644 --- a/apps/maple-agent/app/build.rs +++ b/apps/maple-agent/app/build.rs @@ -61,6 +61,7 @@ fn main() { "api_url": profile.api_url, "billing_api_url": profile.billing_api_url, "web_url": profile.web_url, + "auth_origin": profile.auth_origin, "client_id": profile.client_id, "pcr_environment": profile.pcr_environment, "version": std::env::var("CARGO_PKG_VERSION").expect("Cargo package version"), diff --git a/apps/maple-agent/app/src/backend.rs b/apps/maple-agent/app/src/backend.rs index 467837a53..4dad94912 100644 --- a/apps/maple-agent/app/src/backend.rs +++ b/apps/maple-agent/app/src/backend.rs @@ -35,6 +35,8 @@ use tokio::runtime::Runtime; use tokio::sync::mpsc; use uuid::Uuid; +use crate::hosted_oauth::{ATTEMPT_TIMEOUT, HandoffListener}; + #[derive(Debug, Clone)] pub struct PendingQuestion { pub session_id: String, @@ -97,7 +99,7 @@ pub struct AgentBackend { auth: MapleApiAuthState, api_url: String, persisted_auth: Arc, - pending_oauth: PendingOAuthStore, + pending_oauth: Arc, client_id: Uuid, event_rx: tokio::sync::Mutex>>, billing: crate::billing::BillingClient, @@ -257,6 +259,12 @@ struct PendingOAuth { attempt: Arc, state: Option, completing: bool, + handoff: Option, +} + +struct HostedHandoff { + listener: HandoffListener, + prepared: maple_sdk::PreparedNativeOAuthHandoff, } #[derive(Default)] @@ -274,6 +282,7 @@ impl PendingOAuthStore { attempt: Arc::clone(&attempt), state: None, completing: false, + handoff: None, }) { previous.attempt.cancelled.send_replace(true); } @@ -298,6 +307,7 @@ impl PendingOAuthStore { flow.attempt.provider == provider && flow.state.as_deref() == Some(state) && !flow.completing + && flow.handoff.is_none() }) .ok_or_else(|| { "This callback does not match the pending sign in. Start again.".to_string() @@ -306,6 +316,61 @@ impl PendingOAuthStore { Ok(Arc::clone(&flow.attempt)) } + fn set_handoff( + self: &Arc, + attempt: &Arc, + handoff: HostedHandoff, + ) -> Result<(), String> { + let mut pending = self.0.lock().expect("pending OAuth lock"); + let flow = pending + .as_mut() + .filter(|flow| Arc::ptr_eq(&flow.attempt, attempt)) + .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; + flow.handoff = Some(handoff); + drop(pending); + self.expire_handoff_after(attempt, ATTEMPT_TIMEOUT); + Ok(()) + } + + fn expire_handoff_after( + self: &Arc, + attempt: &Arc, + timeout: std::time::Duration, + ) { + // Expire even if the UI is closed or never begins waiting. Weak + // references cannot keep a backend or completed attempt alive. + let store = Arc::downgrade(self); + let weak_attempt = Arc::downgrade(attempt); + let mut cancelled = attempt.cancelled.subscribe(); + tokio::spawn(async move { + tokio::select! { + _ = cancelled.changed() => {}, + _ = tokio::time::sleep(timeout) => { + if let (Some(store), Some(attempt)) = (store.upgrade(), weak_attempt.upgrade()) { + store.clear(&attempt); + } + } + } + }); + } + + fn take_handoff( + &self, + provider: OAuthProvider, + ) -> Result<(Arc, HostedHandoff), String> { + let mut pending = self.0.lock().expect("pending OAuth lock"); + let flow = pending + .as_mut() + .filter(|flow| flow.attempt.provider == provider && !flow.completing) + .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; + let handoff = flow + .handoff + .take() + .ok_or_else(|| OAUTH_CANCELLED_MESSAGE.to_string())?; + flow.completing = true; + Ok((Arc::clone(&flow.attempt), handoff)) + } + fn clear(&self, attempt: &Arc) { let mut pending = self.0.lock().expect("pending OAuth lock"); if pending @@ -601,7 +666,7 @@ impl PersistedAuthStore { Some(record) } - fn claim_and_persist(&self, snapshot: &MapleApiAuthSnapshot) { + fn claim_and_persist(&self, snapshot: &MapleApiAuthSnapshot) -> bool { let mut state = self .state .lock() @@ -615,10 +680,10 @@ impl PersistedAuthStore { "ignoring stale persisted auth claim (revision {})", snapshot.revision ); - return; + return false; } state.owner = Some(PersistedAuthOwner::from_snapshot(snapshot)); - self.write_locked(&PersistedAuthRecord::from_snapshot(&self.api_url, snapshot)); + self.write_locked(&PersistedAuthRecord::from_snapshot(&self.api_url, snapshot)) } fn persist_rotation(&self, snapshot: &MapleApiAuthSnapshot) { @@ -682,13 +747,15 @@ impl PersistedAuthStore { self.remove_locked(); } - fn write_locked(&self, record: &PersistedAuthRecord) { + fn write_locked(&self, record: &PersistedAuthRecord) -> bool { if let Err(error) = maple_agent::private_file::write_private_json(&self.path, record) { log::error!( "Cannot save credentials to {}: {error}. Sign in is needed again at the next start.", self.path.display() ); + return false; } + true } fn remove_locked(&self) { @@ -752,7 +819,7 @@ impl AgentBackend { auth: MapleApiAuthState::new(), api_url, persisted_auth, - pending_oauth: PendingOAuthStore::default(), + pending_oauth: Arc::new(PendingOAuthStore::default()), client_id: configured_client_id(), event_rx: tokio::sync::Mutex::new(Some(event_rx)), billing, @@ -847,8 +914,8 @@ impl AgentBackend { local_data_root().join("auth.json") } - fn persist_auth(&self, snapshot: &MapleApiAuthSnapshot) { - self.persisted_auth.claim_and_persist(snapshot); + fn persist_auth(&self, snapshot: &MapleApiAuthSnapshot) -> bool { + self.persisted_auth.claim_and_persist(snapshot) } /// The sink the runtime calls when the SDK rotates the token pair @@ -1013,6 +1080,17 @@ impl AgentBackend { access_token: String, refresh_token: Option, ) -> Result { + self.publish_session_with_persistence(user_id, access_token, refresh_token) + .await + .map(|(session, _)| session) + } + + async fn publish_session_with_persistence( + &self, + user_id: String, + access_token: String, + refresh_token: Option, + ) -> Result<(AuthSession, bool), String> { self.auth .set_auth( self.auth_sink(), @@ -1031,12 +1109,19 @@ impl AgentBackend { "Sign in failed. Try again.".to_string() }) .map(|snapshot| { - self.persist_auth(&snapshot); - AuthSession { - user_id: snapshot.user_id.clone(), - } + let persisted = self.persist_auth(&snapshot); + ( + AuthSession { + user_id: snapshot.user_id.clone(), + }, + persisted, + ) }) } + + pub fn uses_hosted_oauth(&self) -> bool { + crate::profile::AUTH_ORIGIN.is_some() + } /// Begin an OAuth flow: returns the authorization URL to open in a /// browser (also opens it via the system browser). pub async fn oauth_start(&self, provider: OAuthProvider) -> Result { @@ -1047,6 +1132,39 @@ impl AgentBackend { retain: false, }; let client = &attempt.client; + if let Some(origin) = crate::profile::AUTH_ORIGIN { + let auth_url = attempt + .while_active(async { + let prepared = client + .prepare_native_oauth_handoff() + .await + .map_err(|_| "Could not prepare browser sign-in. Try again.".to_string())?; + let listener = HandoffListener::bind()?; + let provider_name = match provider { + OAuthProvider::Github => "github", + OAuthProvider::Google => "google", + OAuthProvider::Apple => "apple", + }; + let url = listener.start_url( + origin, + provider_name, + prepared.session_id(), + prepared.request_id(), + ); + self.pending_oauth + .set_handoff(&attempt, HostedHandoff { listener, prepared })?; + Ok(url) + }) + .await?; + if webbrowser::open(&auth_url).is_err() { + return Err( + "Could not open your browser. Check your default browser and try again." + .to_string(), + ); + } + guard.retain = true; + return Ok(auth_url); + } let client_id = self.client_id; let (auth_url, state) = attempt .while_active(async { @@ -1088,6 +1206,53 @@ impl AgentBackend { self.pending_oauth.cancel(); } + /// Wait on the existing listener and consume exactly the SDK handle that + /// created the advertised target. Cancellation closes the listener/socket; + /// malformed callbacks leave it available until the attempt deadline. + pub async fn oauth_wait_for_handoff( + &self, + provider: OAuthProvider, + ) -> Result { + let (attempt, handoff) = self.pending_oauth.take_handoff(provider)?; + let _guard = OAuthAttemptGuard { + store: &self.pending_oauth, + attempt: &attempt, + retain: false, + }; + attempt + .while_active(async { + let HostedHandoff { listener, prepared } = handoff; + let (grant, browser) = listener.receive(&prepared).await?; + let outcome = async { + let response = attempt + .client + .redeem_native_oauth_handoff(prepared, grant) + .await + .map_err(|_| { + "Browser sign-in failed or expired. Start again.".to_string() + })?; + self.publish_session_with_persistence( + response.id.to_string(), + response.access_token, + Some(response.refresh_token), + ) + .await + } + .await; + drop(listener); + browser.finish( + outcome + .as_ref() + .map(|(_, persisted)| *persisted) + .map_err(|_| ()), + ); + // No await after native publication: a late Back or failed browser + // response must not hide an already committed account from the UI. + outcome.map(|(session, _)| session) + }) + .await + } + /// Complete an OAuth flow from the redirected URL (pasted by the user or /// captured from a loopback redirect). pub async fn oauth_complete( @@ -2444,6 +2609,41 @@ mod tests { ); } + #[tokio::test] + async fn hosted_deadline_cancels_its_attempt_but_not_a_replacement() { + let store = Arc::new(PendingOAuthStore::default()); + let expired = store.begin(OAuthProvider::Google, oauth_test_client()); + store.expire_handoff_after(&expired, std::time::Duration::from_millis(10)); + let result = tokio::time::timeout( + std::time::Duration::from_secs(1), + expired.while_active(std::future::pending::>()), + ) + .await + .unwrap(); + assert_eq!(result, Err(OAUTH_CANCELLED_MESSAGE.to_string())); + assert!(store.0.lock().unwrap().is_none()); + + let old = store.begin(OAuthProvider::Google, oauth_test_client()); + store.expire_handoff_after(&old, std::time::Duration::from_millis(10)); + let replacement = store.begin(OAuthProvider::Apple, oauth_test_client()); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + assert!(!*replacement.cancelled.borrow()); + assert!(Arc::ptr_eq( + &store.0.lock().unwrap().as_ref().unwrap().attempt, + &replacement + )); + // Waiting on the hosted path cannot claim or consume a legacy attempt. + assert!(store.take_handoff(OAuthProvider::Apple).is_err()); + store + .set_state(&replacement, "fixture-state".to_string()) + .unwrap(); + assert!( + store + .complete(OAuthProvider::Apple, "fixture-state") + .is_ok() + ); + } + #[tokio::test] async fn oauth_replacement_rejects_late_start_and_preserves_new_attempt_during_old_cleanup() { let store = PendingOAuthStore::default(); @@ -2584,6 +2784,29 @@ mod tests { std::fs::remove_dir_all(root).unwrap(); } + #[test] + fn persistence_reports_write_failure_and_successful_restore() { + let root = std::env::temp_dir().join(format!("maple-auth-persistence-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let path = root.join("auth.json"); + let store = PersistedAuthStore::new(path.clone(), "https://api.example".to_string()); + let snapshot = auth_snapshot("fixture-user", "fixture-session", 1, "fixture-token"); + std::fs::create_dir(&path).unwrap(); + assert!( + !store.claim_and_persist(&snapshot), + "a directory cannot be replaced by credentials" + ); + assert!(store.load().is_none()); + std::fs::remove_dir(&path).unwrap(); + assert!(store.claim_and_persist(&snapshot)); + let restored = PersistedAuthStore::new(path, "https://api.example".to_string()) + .load() + .unwrap(); + assert_eq!(restored.user_id, snapshot.user_id); + assert_eq!(restored.access_token, snapshot.access_token); + std::fs::remove_dir_all(root).unwrap(); + } + #[test] fn rejected_restore_clears_only_the_record_that_was_loaded() { let root = std::env::temp_dir().join(format!( diff --git a/apps/maple-agent/app/src/hosted_oauth.rs b/apps/maple-agent/app/src/hosted_oauth.rs new file mode 100644 index 000000000..549f222a3 --- /dev/null +++ b/apps/maple-agent/app/src/hosted_oauth.rs @@ -0,0 +1,399 @@ +//! The one-attempt browser return endpoint. It accepts a session-bound grant, +//! never provider codes, browser credentials, arbitrary URLs, or a general API. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::time::Duration; + +use maple_sdk::{NativeOAuthHandoffGrant, PreparedNativeOAuthHandoff}; +use rand::RngCore; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpSocket, TcpStream}; + +pub(crate) const ATTEMPT_TIMEOUT: Duration = Duration::from_secs(15 * 60); +const READ_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_REQUEST_BYTES: usize = 8192; +const CALLBACK_PATH: &str = "/auth/callback"; +const RETURN_ERROR: &str = "Browser sign-in could not finish. Start again in Maple Agent."; + +pub(crate) struct HandoffListener { + listener: TcpListener, + port: u16, + state: String, +} + +impl HandoffListener { + pub(crate) fn bind() -> Result { + let socket = TcpSocket::new_v4().map_err(|_| RETURN_ERROR.to_string())?; + // Do not enable address/port reuse. Windows additionally needs an + // exclusive bind so another local process cannot take this endpoint. + #[cfg(windows)] + exclusive_bind(&socket).map_err(|_| RETURN_ERROR.to_string())?; + socket + .bind(SocketAddr::from((Ipv4Addr::LOCALHOST, 0))) + .map_err(|_| RETURN_ERROR.to_string())?; + let listener = socket.listen(8).map_err(|_| RETURN_ERROR.to_string())?; + let port = listener + .local_addr() + .map_err(|_| RETURN_ERROR.to_string())? + .port(); + let mut random = [0_u8; 16]; + rand::rngs::OsRng + .try_fill_bytes(&mut random) + .map_err(|_| RETURN_ERROR.to_string())?; + let state = random.iter().map(|byte| format!("{byte:02x}")).collect(); + Ok(Self { + listener, + port, + state, + }) + } + + pub(crate) fn start_url( + &self, + origin: &str, + provider: &str, + session_id: &str, + request_id: &str, + ) -> String { + let mut url = reqwest::Url::parse(origin).expect("validated compiled Auth origin"); + url.set_path("/agent/start"); + url.query_pairs_mut() + .append_pair("transport", "v2") + .append_pair("provider", provider) + .append_pair("native_session_id", session_id) + .append_pair("native_request_id", request_id) + .append_pair("return_port", &self.port.to_string()) + .append_pair("return_state", &self.state); + url.into() + } + + pub(crate) async fn receive( + &self, + prepared: &PreparedNativeOAuthHandoff, + ) -> Result<(NativeOAuthHandoffGrant, CompletionResponse), String> { + self.receive_matching(|grant| prepared.matches_untrusted_grant_target(grant)) + .await + } + + async fn receive_matching( + &self, + matches_target: impl Fn(&NativeOAuthHandoffGrant) -> bool, + ) -> Result<(NativeOAuthHandoffGrant, CompletionResponse), String> { + loop { + let (mut stream, peer) = self + .listener + .accept() + .await + .map_err(|_| RETURN_ERROR.to_string())?; + if peer.ip() != Ipv4Addr::LOCALHOST { + continue; + } + let grant = tokio::time::timeout(READ_TIMEOUT, self.read_grant(&mut stream)) + .await + .ok() + .flatten(); + if let Some(grant) = grant.filter(|grant| matches_target(grant)) { + return Ok((grant, CompletionResponse(stream))); + } + // A stray navigation, favicon, malformed request or wrong state + // must not consume the SDK's one permitted redemption request. + let _ = tokio::time::timeout(READ_TIMEOUT, stream.write_all( + b"HTTP/1.1 400 Bad Request\r\nConnection: close\r\nCache-Control: no-store\r\nContent-Length: 0\r\n\r\n" + )).await; + } + } + + async fn read_grant(&self, stream: &mut TcpStream) -> Option { + let mut bytes = Vec::with_capacity(1024); + let mut chunk = [0_u8; 1024]; + loop { + let read = stream.read(&mut chunk).await.ok()?; + if read == 0 || bytes.len() + read > MAX_REQUEST_BYTES { + return None; + } + bytes.extend_from_slice(&chunk[..read]); + if bytes.windows(4).any(|window| window == b"\r\n\r\n") { + return parse_request(&bytes, self.port, &self.state); + } + } + } +} + +fn parse_request(bytes: &[u8], port: u16, state: &str) -> Option { + if bytes.len() > MAX_REQUEST_BYTES { + return None; + } + let mut headers = [httparse::EMPTY_HEADER; 32]; + let mut request = httparse::Request::new(&mut headers); + let consumed = match request.parse(bytes).ok()? { + httparse::Status::Complete(consumed) => consumed, + httparse::Status::Partial => return None, + }; + if consumed != bytes.len() || request.method != Some("GET") || request.version != Some(1) { + return None; + } + let host = format!("127.0.0.1:{port}"); + let mut hosts = request + .headers + .iter() + .filter(|header| header.name.eq_ignore_ascii_case("host")); + if hosts.next()?.value != host.as_bytes() || hosts.next().is_some() { + return None; + } + if request.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("transfer-encoding") + || (header.name.eq_ignore_ascii_case("content-length") && header.value != b"0") + }) { + return None; + } + let target = request.path?; + let (path, _) = target.split_once('?')?; + if path != CALLBACK_PATH || target.contains('#') { + return None; + } + let url = reqwest::Url::parse(&format!("http://{host}{target}")).ok()?; + let mut received_state = None; + let mut grant = None; + for (key, value) in url.query_pairs() { + match key.as_ref() { + "return_state" if received_state.is_none() => received_state = Some(value), + "handoff_grant" if grant.is_none() => grant = Some(value), + _ => return None, + } + } + if received_state?.as_ref() != state { + return None; + } + NativeOAuthHandoffGrant::new(grant?.into_owned()).ok() +} + +#[cfg(windows)] +fn exclusive_bind(socket: &TcpSocket) -> std::io::Result<()> { + use std::os::windows::io::AsRawSocket; + use windows_sys::Win32::Networking::WinSock::{ + SO_EXCLUSIVEADDRUSE, SOL_SOCKET, WSAGetLastError, setsockopt, + }; + let enabled = 1_i32; + // SAFETY: a live Winsock socket, a valid i32 option pointer and its exact + // size. setsockopt does not retain the pointer after returning. + let result = unsafe { + setsockopt( + socket.as_raw_socket() as _, + SOL_SOCKET, + SO_EXCLUSIVEADDRUSE, + (&enabled as *const i32).cast(), + std::mem::size_of_val(&enabled) as i32, + ) + }; + if result == 0 { + Ok(()) + } else { + // SAFETY: reading the current thread's Winsock error has no arguments + // or retained pointers. + Err(std::io::Error::from_raw_os_error(unsafe { + WSAGetLastError() + })) + } +} + +pub(crate) struct CompletionResponse(TcpStream); + +impl CompletionResponse { + /// Native publication decides the message. Delivery failure cannot undo a + /// committed session, and never causes another redemption or redirect. + pub(crate) fn finish(self, outcome: Result) { + tokio::spawn(async move { + let message = match outcome { + Ok(true) => "Signed in to Maple Agent. You can close this tab.", + Ok(false) => { + "Signed in to Maple Agent, but the session could not be saved. You will need to sign in again when you restart the app." + } + Err(()) => "Sign-in could not finish. Return to Maple Agent and start again.", + }; + let body = format!( + "Maple Agent sign-in

{message}

" + ); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\nReferrer-Policy: no-referrer\r\nX-Content-Type-Options: nosniff\r\nContent-Security-Policy: default-src 'none'; script-src 'sha256-TT2/B5X4kutghZnTdCndWuEaf6uS6wbgBxuJIAzSdos='; frame-ancestors 'none'; base-uri 'none'\r\n\r\n{body}", + body.len() + ); + let mut stream = self.0; + let _ = tokio::time::timeout(READ_TIMEOUT, stream.write_all(response.as_bytes())).await; + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const STATE: &str = "0123456789abcdef0123456789abcdef"; + const GRANT: &str = "e30.e30.c2ln"; + + fn request(port: u16, state: &str) -> String { + format!( + "GET /auth/callback?handoff_grant={GRANT}&return_state={state} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\n\r\n" + ) + } + + #[test] + fn rejects_wrong_boundary_and_duplicate_or_oversized_input() { + let valid = request(34567, STATE); + assert!(parse_request(valid.as_bytes(), 34567, STATE).is_some()); + for invalid in [ + valid.replace("GET ", "POST "), + valid.replace("/auth/callback?", "/other?"), + valid.replace("/auth/callback?", "http://127.0.0.1:34567/auth/callback?"), + valid.replace("Host: 127.0.0.1:34567", "Host: attacker.example"), + valid.replace("Host: ", "Host: 127.0.0.1:34567\r\nHost: "), + valid.replace("HTTP/1.1", "HTTP/1.0"), + valid.replace("?handoff_grant=", "?other=x&handoff_grant="), + valid.replace("?handoff_grant=", "?handoff_grant=bad&handoff_grant="), + valid.replace("?handoff_grant=", "?return_state=bad&handoff_grant="), + valid.replace(STATE, "wrong"), + valid.replace(GRANT, "not-a-grant"), + valid.replace("\r\n\r\n", "\r\nContent-Length: 1\r\n\r\nx"), + valid.replace("\r\n\r\n", "\r\nTransfer-Encoding: chunked\r\n\r\n"), + valid.replace(" HTTP/", "#fragment HTTP/"), + valid.replace(GRANT, &"a".repeat(MAX_REQUEST_BYTES)), + ] { + assert!(parse_request(invalid.as_bytes(), 34567, STATE).is_none()); + } + assert!(parse_request(valid.as_bytes(), 34568, STATE).is_none()); + } + + #[tokio::test] + async fn start_url_advertises_only_the_bound_native_target() { + let listener = HandoffListener::bind().unwrap(); + let session = "a".repeat(32); + let request = "b".repeat(32); + let url = reqwest::Url::parse(&listener.start_url( + "https://auth-dev.maple.ai", + "google", + &session, + &request, + )) + .unwrap(); + assert_eq!( + url.origin().ascii_serialization(), + "https://auth-dev.maple.ai" + ); + assert_eq!(url.path(), "/agent/start"); + let query: std::collections::BTreeMap<_, _> = url.query_pairs().collect(); + assert_eq!(query.len(), 6); + assert_eq!(query["transport"], "v2"); + assert_eq!(query["provider"], "google"); + assert_eq!(query["native_session_id"], session); + assert_eq!(query["native_request_id"], request); + assert_eq!(query["return_port"], listener.port.to_string()); + assert_eq!(query["return_state"], listener.state); + } + + #[tokio::test] + async fn listener_rejects_invalid_requests_then_delivers_once_and_closes() { + let listener = HandoffListener::bind().unwrap(); + let address = listener.listener.local_addr().unwrap(); + assert_eq!(address.ip(), Ipv4Addr::LOCALHOST); + assert_eq!(listener.state.len(), 32); + assert!( + listener + .state + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + ); + assert!(std::net::TcpListener::bind(address).is_err()); + let valid = request(listener.port, &listener.state); + let invalid = request(listener.port, "wrong-state"); + let task = tokio::spawn(async move { + let (_, response) = listener.receive_matching(|_| true).await.unwrap(); + drop(listener); + response.finish(Ok(true)); + }); + let mut wrong = TcpStream::connect(address).await.unwrap(); + wrong.write_all(invalid.as_bytes()).await.unwrap(); + let mut rejected = String::new(); + wrong.read_to_string(&mut rejected).await.unwrap(); + assert!(rejected.starts_with("HTTP/1.1 400")); + let mut browser = TcpStream::connect(address).await.unwrap(); + browser.write_all(valid.as_bytes()).await.unwrap(); + let mut response = String::new(); + browser.read_to_string(&mut response).await.unwrap(); + task.await.unwrap(); + assert!(response.starts_with("HTTP/1.1 200")); + assert!(response.contains("Signed in to Maple Agent")); + assert!(response.contains("Cache-Control: no-store")); + assert!(response.contains("history.replaceState")); + assert!(!response.contains(GRANT)); + assert!(!response.contains("Location:")); + assert!(TcpStream::connect(address).await.is_err()); + } + + #[tokio::test] + async fn wrong_grant_target_does_not_consume_listener() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let listener = HandoffListener::bind().unwrap(); + let address = listener.listener.local_addr().unwrap(); + let valid = request(listener.port, &listener.state); + let task = tokio::spawn(async move { + let examined = AtomicUsize::new(0); + let (_, response) = listener + .receive_matching(|_| examined.fetch_add(1, Ordering::SeqCst) > 0) + .await + .unwrap(); + assert_eq!(examined.load(Ordering::SeqCst), 2); + response.finish(Ok(false)); + }); + for expected in ["400 Bad Request", "200 OK"] { + let mut browser = TcpStream::connect(address).await.unwrap(); + browser.write_all(valid.as_bytes()).await.unwrap(); + let mut response = String::new(); + browser.read_to_string(&mut response).await.unwrap(); + assert!(response.starts_with(&format!("HTTP/1.1 {expected}"))); + if expected == "200 OK" { + assert!(response.contains("session could not be saved")); + assert!(!response.contains(GRANT)); + } + } + task.await.unwrap(); + } + + #[tokio::test] + async fn incomplete_request_times_out_without_consuming_listener() { + let listener = HandoffListener::bind().unwrap(); + let address = listener.listener.local_addr().unwrap(); + let valid = request(listener.port, &listener.state); + let task = tokio::spawn(async move { + let (_, response) = listener.receive_matching(|_| true).await.unwrap(); + response.finish(Err(())); + }); + let mut stalled = TcpStream::connect(address).await.unwrap(); + stalled.write_all(b"GET /auth").await.unwrap(); + let mut rejected = String::new(); + tokio::time::timeout( + Duration::from_secs(4), + stalled.read_to_string(&mut rejected), + ) + .await + .unwrap() + .unwrap(); + assert!(rejected.starts_with("HTTP/1.1 400")); + let mut browser = TcpStream::connect(address).await.unwrap(); + browser.write_all(valid.as_bytes()).await.unwrap(); + let mut response = String::new(); + browser.read_to_string(&mut response).await.unwrap(); + assert!(response.contains("Sign-in could not finish")); + assert!(!response.contains("Signed in to")); + task.await.unwrap(); + } + + #[tokio::test] + async fn dropping_wait_closes_listener_without_a_callback() { + let listener = HandoffListener::bind().unwrap(); + let address = listener.listener.local_addr().unwrap(); + let task = tokio::spawn(async move { listener.receive_matching(|_| true).await }); + tokio::task::yield_now().await; + task.abort(); + assert!(matches!(task.await, Err(error) if error.is_cancelled())); + assert!(TcpStream::connect(address).await.is_err()); + } +} diff --git a/apps/maple-agent/app/src/main.rs b/apps/maple-agent/app/src/main.rs index 15a9fe4cf..4d1570563 100644 --- a/apps/maple-agent/app/src/main.rs +++ b/apps/maple-agent/app/src/main.rs @@ -11,6 +11,7 @@ mod billing; #[cfg(feature = "desktop")] mod desktop; mod env; +mod hosted_oauth; #[cfg(feature = "desktop")] mod keymap; #[cfg(feature = "desktop")] diff --git a/apps/maple-agent/app/src/profile.rs b/apps/maple-agent/app/src/profile.rs index ce1853a79..340524c9a 100644 --- a/apps/maple-agent/app/src/profile.rs +++ b/apps/maple-agent/app/src/profile.rs @@ -29,6 +29,7 @@ pub fn build_info() -> serde_json::Value { serde_json::from_slice(&MAPLE_AGENT_BUILD_INFO).expect("generated public build metadata"); debug_assert_eq!(info["display_name"], DISPLAY_NAME); debug_assert_eq!(info["bundle_id"], BUNDLE_ID); + 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["prerelease"], PRERELEASE); @@ -87,6 +88,7 @@ mod tests { assert_eq!(info["display_name"], DISPLAY_NAME); assert_eq!(info["billing_api_url"], BILLING_API_URL); assert_eq!(info["web_url"], WEB_URL); + 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["prerelease"], PRERELEASE); diff --git a/apps/maple-agent/app/src/ui/login.rs b/apps/maple-agent/app/src/ui/login.rs index 16cad450a..08fec170a 100644 --- a/apps/maple-agent/app/src/ui/login.rs +++ b/apps/maple-agent/app/src/ui/login.rs @@ -24,6 +24,9 @@ enum OAuthFlow { provider: OAuthProvider, auth_url: String, }, + Hosted { + provider: OAuthProvider, + }, } pub struct LoginScreen { @@ -36,6 +39,9 @@ pub struct LoginScreen { /// Backend-call bridges retained for thread-affinity; see /// [`crate::ui::task::call`]. bridged_tasks: std::cell::RefCell>>, + /// Abort backend work when this screen is destroyed, including an OAuth + /// start that has been scheduled but has not created its native attempt. + backend_tasks: std::cell::RefCell>, error: Option, busy: bool, } @@ -83,6 +89,7 @@ impl LoginScreen { }); Self { bridged_tasks: std::cell::RefCell::new(Vec::new()), + backend_tasks: std::cell::RefCell::new(Vec::new()), backend, email_input: email, password_input: password, @@ -133,12 +140,27 @@ impl LoginScreen { T: Send + 'static, F: std::future::Future> + Send + 'static, { - // Retained for the same thread-affinity reason as ChatScreen's - // bridges; see ui::task::call. - let bridge = crate::ui::task::call(&self.backend, future, cx, |this, result, cx| { - this.busy = false; - then(this, result, cx); - cx.notify(); + let task = self.backend.spawn(future); + let mut tasks = self.backend_tasks.borrow_mut(); + if tasks.len() >= 16 { + tasks.retain(|task| !task.is_finished()); + } + tasks.push(task.abort_handle()); + drop(tasks); + // Keep the same thread-affinity bridge as ui::task::call, with an + // abort handle owned by this login screen. cx.spawn receives only a + // WeakEntity; waiting for the browser cannot keep the screen alive. + let bridge = cx.spawn(async move |this, cx| { + let result = task.await.unwrap_or_else(|error| { + log::debug!("backend task failed: {error:?}"); + Err("The task was cancelled".to_string()) + }); + this.update(cx, |this, cx| { + this.busy = false; + then(this, result, cx); + cx.notify(); + }) + .ok(); }); crate::ui::task::retain(&self.bridged_tasks, bridge); } @@ -162,7 +184,16 @@ impl LoginScreen { self.call( async move { backend.oauth_start(provider).await }, cx, - move |this, result, _cx| match result { + move |this, result, cx| match result { + Ok(_) if this.backend.uses_hosted_oauth() => { + this.oauth = OAuthFlow::Hosted { provider }; + let backend = this.begin(cx); + this.call( + async move { backend.oauth_wait_for_handoff(provider).await }, + cx, + Self::oauth_completed, + ); + } Ok(auth_url) => this.oauth = OAuthFlow::Pending { provider, auth_url }, Err(message) => this.error = Some(message), }, @@ -192,8 +223,8 @@ impl LoginScreen { fn oauth_completed(&mut self, result: Result, cx: &mut Context) { match result { - // Success means native authentication is already installed and - // persisted. If Back raced with its queued UI receipt, follow + // Success means native authentication is already installed. If + // Back raced with its queued UI receipt, follow // that committed account instead of leaving a signed-out form // over a live session. `busy` prevents another sign-in until // this completion is delivered. @@ -212,6 +243,19 @@ impl LoginScreen { } } +impl Drop for LoginScreen { + fn drop(&mut self) { + // The screen owns the native attempt, including any loopback socket. + // The bridge holds only a weak view handle while awaiting it. + self.backend.cancel_oauth(); + // Dropping a Tokio JoinHandle merely detaches it. Abort explicitly + // so a not-yet-polled start cannot open a browser after cancellation. + for task in self.backend_tasks.get_mut() { + task.abort(); + } + } +} + impl EventEmitter for LoginScreen {} impl Render for LoginScreen { @@ -227,7 +271,10 @@ impl Render for LoginScreen { .bg(gpui::rgb(theme::bg_elevated())) .border_1() .border_color(gpui::rgb(theme::border())) - .when(busy, |container| container.opacity(0.7)) + .when( + busy && !matches!(self.oauth, OAuthFlow::Hosted { .. }), + |container| container.opacity(0.7), + ) .tab_group() .child( div() @@ -321,6 +368,7 @@ impl Render for LoginScreen { .child(field("", self.callback_input.clone())) .child( widgets::primary_button("oauth-confirm") + .debug_selector(|| "oauth-confirm".to_string()) .w_full() .when(busy, |el| el.bg(gpui::rgb(theme::bg_sidebar_pill()))) .when(!busy, |el| { @@ -336,6 +384,39 @@ impl Render for LoginScreen { ) .child( widgets::ghost_button("oauth-cancel") + .debug_selector(|| "oauth-cancel".to_string()) + .w_full() + .on_click(cx.listener(|this, _event, _window, cx| { + this.cancel_oauth(cx); + })) + .child("Back to email sign in"), + ); + } + OAuthFlow::Hosted { provider } => { + card = card + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_primary())) + .child(format!("Finish signing in with {}", provider.label())), + ) + .child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_secondary())) + .child("Continue in your browser. Maple will sign in automatically when you finish."), + ) + .when(busy, |card| { + card.child( + div() + .text_sm() + .text_color(gpui::rgb(theme::text_muted())) + .child("Waiting for your browser…"), + ) + }) + .child( + widgets::ghost_button("oauth-cancel") + .debug_selector(|| "oauth-cancel".to_string()) .w_full() .on_click(cx.listener(|this, _event, _window, cx| { this.cancel_oauth(cx); @@ -480,6 +561,26 @@ mod tests { #[gpui::test] fn oauth_committed_success_is_delivered_when_back_precedes_ui_receipt(cx: &mut TestAppContext) { + assert_committed_success_after_back( + OAuthFlow::Pending { + provider: OAuthProvider::Github, + auth_url: "https://example.com/authorize".to_string(), + }, + cx, + ); + } + + #[gpui::test] + fn hosted_oauth_committed_success_is_delivered_after_back(cx: &mut TestAppContext) { + assert_committed_success_after_back( + OAuthFlow::Hosted { + provider: OAuthProvider::Github, + }, + cx, + ); + } + + fn assert_committed_success_after_back(flow: OAuthFlow, cx: &mut TestAppContext) { cx.executor().allow_parking(); let backend = Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()).unwrap()); @@ -498,10 +599,7 @@ mod tests { user_id: "oauth-account-fixture".to_string(), }); screen.update(cx, |this, cx| { - this.oauth = OAuthFlow::Pending { - provider: OAuthProvider::Github, - auth_url: "https://example.com/authorize".to_string(), - }; + this.oauth = flow; this.busy = true; this.cancel_oauth(cx); assert!( @@ -526,4 +624,75 @@ mod tests { }); assert_eq!(signed_in.borrow().len(), 1); } + + #[gpui::test] + fn hosted_wait_has_no_paste_step_and_back_remains_usable(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()).unwrap()); + let screen = cx.new(|cx| LoginScreen::new(backend, cx)); + screen.update(cx, |this, cx| { + this.oauth = OAuthFlow::Hosted { + provider: OAuthProvider::Google, + }; + this.busy = true; + cx.notify(); + }); + let (_host, cx) = cx.add_window_view(|_window, _cx| LoginHost { + screen: screen.clone(), + }); + cx.simulate_resize(size(px(900.), px(700.))); + assert!(cx.debug_bounds("oauth-confirm").is_none()); + let back = cx + .debug_bounds("oauth-cancel") + .expect("cancel while waiting"); + cx.simulate_click(back.center(), gpui::Modifiers::default()); + screen.update(cx, |this, cx| { + assert!(matches!(this.oauth, OAuthFlow::Idle)); + assert!(this.busy, "wait result must settle before another sign-in"); + this.submit_values(String::new(), String::new(), cx); + this.start_oauth(OAuthProvider::Apple, cx); + assert!(this.error.is_none()); + assert!(matches!(this.oauth, OAuthFlow::Idle)); + assert!(this.busy); + this.busy = false; + this.oauth_completed(Err("Sign in was cancelled. Start again.".to_string()), cx); + assert!(this.error.is_none()); + }); + } + + #[gpui::test] + async fn waiting_bridge_drops_view_and_aborts_backend_work(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + let backend = + Arc::new(AgentBackend::new("http://127.0.0.1:9".to_string(), String::new()).unwrap()); + let screen = cx.new(|cx| LoginScreen::new(backend.clone(), cx)); + let weak = screen.downgrade(); + let (owned, released) = tokio::sync::oneshot::channel::<()>(); + screen.update(cx, |this, cx| { + this.oauth = OAuthFlow::Hosted { + provider: OAuthProvider::Apple, + }; + this.begin(cx); + this.call( + async move { + let _owned = owned; + std::future::pending::>().await + }, + cx, + LoginScreen::oauth_completed, + ); + }); + cx.run_until_parked(); + drop(screen); + cx.run_until_parked(); + assert!( + weak.upgrade().is_none(), + "closing the login screen must reach Drop and cancel its native attempt" + ); + assert!( + released.await.is_err(), + "screen disposal must abort, rather than detach, its backend work" + ); + } } diff --git a/apps/maple-agent/docs/release-profiles.md b/apps/maple-agent/docs/release-profiles.md index 2e48721d9..0683ef1a0 100644 --- a/apps/maple-agent/docs/release-profiles.md +++ b/apps/maple-agent/docs/release-profiles.md @@ -11,9 +11,18 @@ ID, or PCR trust environment. | Dev | Maple Agent Dev | `cloud.opensecret.maple.agent.dev` | `maple-agent-dev` | Hosted Dev API and billing; development PCR | | Prod | Maple Agent | `cloud.opensecret.maple.agent` | `maple-agent-prod` | Hosted Prod API and billing; production PCR | -The public endpoints and client ID match `scripts/ci/_common.sh`'s existing +The API endpoints and client ID match `scripts/ci/_common.sh`'s existing Research Dev and Prod service selection. Billing browser pricing and checkout -return pages retain the existing `https://trymaple.ai` destinations. +return pages use the unchanged `web_url`: `https://app-dev.trymaple.ai` for Dev +and `https://trymaple.ai` for Prod. + +Packaged Dev selects the separate compiled `auth_origin`, +`https://auth-dev.maple.ai`, for hosted native sign-in with a loopback return. +The app waits for browser completion and allows cancellation; no callback URL +needs to be pasted. Prod and unpackaged builds have `auth_origin: null` and +retain their existing provider callback paste flow. Hosted sign-in cannot be +enabled or redirected through an inherited environment variable. Activating +it for production requires a separately reviewed profile change. 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 @@ -37,7 +46,7 @@ Debug. Release packaging accepts only Dev or Prod metadata. `maple-agent --build-info` prints one JSON object and exits before changing the 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`, `client_id`, `pcr_environment`, `version`, `git_revision`, +`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 revision with a possible `-dirty` suffix; `source_sha` is the full source commit. Tarball/pure Nix builds without Git report `unknown` for unavailable revisions. diff --git a/apps/maple-agent/release-profiles.json b/apps/maple-agent/release-profiles.json index 10352bf72..9338df755 100644 --- a/apps/maple-agent/release-profiles.json +++ b/apps/maple-agent/release-profiles.json @@ -6,6 +6,7 @@ "api_url": "https://enclave.secretgpt.ai", "billing_api_url": "https://billing-dev.opensecret.cloud", "web_url": "https://app-dev.trymaple.ai", + "auth_origin": "https://auth-dev.maple.ai", "client_id": "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6", "pcr_environment": "development", "update_tag_prefix": "maple-agent-dev-v", @@ -18,6 +19,7 @@ "api_url": "https://enclave.trymaple.ai", "billing_api_url": "https://billing.opensecret.cloud", "web_url": "https://trymaple.ai", + "auth_origin": null, "client_id": "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6", "pcr_environment": "production", "update_tag_prefix": "maple-agent-v", diff --git a/apps/maple-agent/release_profile.rs b/apps/maple-agent/release_profile.rs index 2396bdf4c..6337277c1 100644 --- a/apps/maple-agent/release_profile.rs +++ b/apps/maple-agent/release_profile.rs @@ -13,6 +13,7 @@ pub struct ReleaseProfile { pub api_url: String, pub billing_api_url: String, pub web_url: String, + pub auth_origin: Option, pub client_id: String, pub pcr_environment: String, pub update_tag_prefix: String, @@ -61,6 +62,12 @@ pub fn profiles() -> Result, String> { } else { "https://trymaple.ai" } + || profile.auth_origin.as_deref() + != if channel == "dev" { + Some("https://auth-dev.maple.ai") + } else { + None + } || profile.client_id != "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6" { return Err(format!( @@ -99,6 +106,9 @@ pub fn select_profile( profile.display_name = "Maple Agent Debug".into(); profile.bundle_id = "cloud.opensecret.maple.agent.debug".into(); profile.data_namespace = "maple-agent".into(); + // Hosted return is admitted only by the packaged Dev profile. Local + // environment overrides must not opt an unpackaged client into it. + profile.auth_origin = None; // 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 { @@ -157,6 +167,16 @@ pub fn emit(manifest_path: &str, helper_path: &str, app: bool) { let literal = serde_json::to_string(value).expect("profile string"); constants.push_str(&format!("pub const {name}: &str = {literal};\n")); } + let auth_origin = match profile.auth_origin.as_deref() { + Some(origin) => format!( + "Some({})", + serde_json::to_string(origin).expect("profile auth origin") + ), + None => "None".to_string(), + }; + constants.push_str(&format!( + "pub const AUTH_ORIGIN: Option<&str> = {auth_origin};\n" + )); constants.push_str(&format!( "pub const PRERELEASE: bool = {};\n", profile.prerelease @@ -183,6 +203,11 @@ mod tests { assert_ne!(dev.billing_api_url, prod.billing_api_url); assert_eq!(dev.web_url, "https://app-dev.trymaple.ai"); assert_eq!(prod.web_url, "https://trymaple.ai"); + assert_eq!( + dev.auth_origin.as_deref(), + Some("https://auth-dev.maple.ai") + ); + assert_eq!(prod.auth_origin, None); assert_ne!(dev.pcr_environment, prod.pcr_environment); assert_ne!(dev.update_tag_prefix, prod.update_tag_prefix); assert!(dev.prerelease); @@ -212,5 +237,6 @@ mod tests { assert_eq!(profile.data_namespace, "maple-agent"); assert_eq!(profile.pcr_environment, "development"); assert_eq!(profile.bundle_id, "cloud.opensecret.maple.agent.debug"); + assert_eq!(profile.auth_origin, None); } } diff --git a/apps/maple-agent/scripts/release-info.py b/apps/maple-agent/scripts/release-info.py index bae9d916e..5cfd15ffe 100755 --- a/apps/maple-agent/scripts/release-info.py +++ b/apps/maple-agent/scripts/release-info.py @@ -14,7 +14,7 @@ COMPONENT = Path(__file__).resolve().parent.parent PUBLIC_FIELDS = ( - "display_name", "bundle_id", "data_namespace", "api_url", "billing_api_url", "web_url", + "display_name", "bundle_id", "data_namespace", "api_url", "billing_api_url", "web_url", "auth_origin", "client_id", "pcr_environment", "update_tag_prefix", "prerelease", ) BUILD_FIELDS = set(PUBLIC_FIELDS) | {"profile", "version", "git_revision", "source_sha"} @@ -37,7 +37,9 @@ def validate_info(profile, info, source_sha=None): raise ValueError(f"binary {field} does not match the checked-in release profile") if not isinstance(info["prerelease"], bool): raise ValueError("binary prerelease marker must be a boolean") - if any(not isinstance(info[field], str) for field in BUILD_FIELDS - {"prerelease"}): + 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"}): 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-auth/AGENTS.md b/apps/maple-auth/AGENTS.md index aa5712d39..d2275eacf 100644 --- a/apps/maple-auth/AGENTS.md +++ b/apps/maple-auth/AGENTS.md @@ -19,7 +19,11 @@ trust. Dev requires `native_app_variant=dev` and returns only through `cloud.opensecret.maple.dev`; Prod retains the four-parameter request and `cloud.opensecret.maple` return. Bind that identity to pending state and recheck it before mint/open. Do not accept arbitrary URLs, schemes, or query-selected -backend configuration. Client adoption and Agent return paths are separate. +backend configuration. Agent uses a separate `/agent/start` contract with a +canonical port and random return state, fixed `127.0.0.1` callback path, and +compiled environment label. Bind all target fields through callback and mint. +Require a second user click for loopback return and enforce the issuer's grant +expiry. Research's automatic return remains unchanged. Preserve V2-only route parsing, same-origin OAuth callbacks, popup-only Apple, SDK bootstrap ordering, pending target and account ownership checks, one mint diff --git a/apps/maple-auth/README.md b/apps/maple-auth/README.md index bb0ec813a..c94d60bb2 100644 --- a/apps/maple-auth/README.md +++ b/apps/maple-auth/README.md @@ -19,6 +19,8 @@ build and publication controls. additionally requires `native_app_variant=dev`. Prod rejects that parameter; Dev rejects the Prod request form. Neither accepts a caller-selected backend, scheme, return URL, or project. +- `/agent/start` accepts the four V2 fields plus `return_port` and `return_state` + for the fixed loopback contract below. It never accepts `native_app_variant`. - `/auth/github/callback` and `/auth/google/callback` use the same-origin SDK pending state. OAuth initiation explicitly selects this origin's callback. - Apple uses its popup flow. `/auth/apple/callback` only explains how to restart @@ -48,6 +50,35 @@ prevents a late grant from opening the app. The manual Open Maple link remains a a successful mint. SDK credentials remain on this origin; finishing a handoff does not sign out another tab or the user. +## Agent return contract + +Agent opens `/agent/start?transport=v2&provider=...&native_session_id=...&native_request_id=...&return_port=...&return_state=...`. +Each field occurs exactly once. Session/request IDs and `return_state` are +32 lowercase hex characters; the native client generates a fresh random +16-byte return state and binds its IPv4 loopback listener before opening Auth. +`return_port` is canonical decimal from 1 through 65535. No hostname, path, +URL, app variant, or environment may be supplied in the request. + +Auth keeps the compiled environment, Agent identity, port and state in the +pending target alongside the provider/session/request pair. These fields must +still match after provider callbacks and minting. Account confirmation mints +once and shows **Return to Maple Agent Dev** (Dev) or **Return to Maple Agent** +(Prod). Agent return requires that separate button click; Auth never navigates +to loopback automatically. The top-level destination is exactly: + +```text +http://127.0.0.1:/auth/callback?handoff_grant=&return_state= +``` + +The URL stays in component memory and expires at the earlier of the SDK's +`expires_at` (Unix seconds) and the pending attempt's deadline. Manual return +rechecks account ownership, credential revision, expiry, and replacement flows. +The native listener validates its state, redeems the grant through the reserved +native session/request, and publishes the authenticated account. Credentials +remain stored on Auth. Research keeps its automatic return and manual fallback. +Auth supports the Agent route in both profiles; native Agent Prod adoption is +separately disabled until its production configuration is approved. + ## Develop and validate From the repository root, enter the pinned toolchain and install this app only: diff --git a/apps/maple-auth/src/auth-site/HostedStart.tsx b/apps/maple-auth/src/auth-site/HostedStart.tsx index c52472bed..1f7a09989 100644 --- a/apps/maple-auth/src/auth-site/HostedStart.tsx +++ b/apps/maple-auth/src/auth-site/HostedStart.tsx @@ -5,6 +5,7 @@ import { isCurrentDesktopOAuthTarget, markTransportV2DesktopOAuth, readTransportV2DesktopOAuth, + type NativeOAuthInput, type TransportV2DesktopOAuthState } from "@/services/desktopOAuthTransport"; import { getBrowserOAuthCallbackUrl } from "@/services/oauthConfig"; @@ -24,12 +25,24 @@ export function HostedStart({ route }: { route: Extract { try { - const handoffInput = { + const common = { provider: route.provider, nativeSessionId: route.nativeSessionId, - nativeRequestId: route.nativeRequestId, - ...(route.nativeAppVariant ? { nativeAppVariant: route.nativeAppVariant } : {}) + nativeRequestId: route.nativeRequestId }; + const handoffInput: NativeOAuthInput = + route.nativeApp === "agent" + ? { + ...common, + nativeApp: "agent", + returnPort: route.returnPort, + returnState: route.returnState, + environment: route.environment + } + : { + ...common, + ...(route.nativeAppVariant ? { nativeAppVariant: route.nativeAppVariant } : {}) + }; markTransportV2DesktopOAuth(handoffInput); const pending = readTransportV2DesktopOAuth(route.provider); if (!pending) throw new Error("Native sign-in is unavailable"); diff --git a/apps/maple-auth/src/auth-site/agentReturn.test.ts b/apps/maple-auth/src/auth-site/agentReturn.test.ts new file mode 100644 index 000000000..aa5533512 --- /dev/null +++ b/apps/maple-auth/src/auth-site/agentReturn.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; + +const appDirectory = fileURLToPath(new URL("../..", import.meta.url)); +const cases = 16; + +describe("Agent return UI with the real SDK context", () => { + for (const environment of ["production", "development"]) { + test(`${environment} runs every Agent return case in isolation`, () => { + const result = Bun.spawnSync( + [ + process.execPath, + "--no-env-file", + "test", + "./src/auth-site/fixtures/AgentReturn.case.tsx" + ], + { + cwd: appDirectory, + env: { + PATH: process.env.PATH, + LANG: "C.UTF-8", + NO_COLOR: "1", + VITE_AUTH_ENVIRONMENT: environment + }, + stdout: "pipe", + stderr: "pipe", + timeout: 10_000 + } + ); + const output = + new TextDecoder().decode(result.stdout) + new TextDecoder().decode(result.stderr); + if (result.exitCode !== 0) throw new Error(`Agent return child tests failed:\n${output}`); + expect(output).toMatch(new RegExp(`\\b${cases} pass\\b`)); + expect(output).toMatch(/\b0 fail\b/u); + expect(output).toMatch(new RegExp(`Ran ${cases} tests across 1 file`)); + }, 15_000); + } +}); diff --git a/apps/maple-auth/src/auth-site/fixtures/AgentReturn.case.tsx b/apps/maple-auth/src/auth-site/fixtures/AgentReturn.case.tsx new file mode 100644 index 000000000..328400729 --- /dev/null +++ b/apps/maple-auth/src/auth-site/fixtures/AgentReturn.case.tsx @@ -0,0 +1,337 @@ +import { authEnvironment } from "@/config/authEnvironment"; +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import type { Provider } from "react"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { + OpenSecretContext, + installNativeOAuthHandoffCredentials, + prepareNativeOAuthHandoff, + readNativeUserAuth, + type OpenSecretContextType +} from "@mapleai/sdk"; +import { HostedNativeSignInConfirmation } from "@/components/HostedNativeSignInConfirmation"; +import { + markTransportV2DesktopOAuth, + readTransportV2DesktopOAuth, + TRANSPORT_V2_PENDING_TTL_MS, + type DesktopOAuthProvider, + type TransportV2DesktopOAuthState +} from "@/services/desktopOAuthTransport"; + +const SdkProvider = OpenSecretContext.Provider as unknown as Provider; + +class MemoryStorage implements Storage { + values = new Map(); + get length() { + return this.values.size; + } + clear() { + this.values.clear(); + } + key(index: number) { + return [...this.values.keys()][index] ?? null; + } + getItem(key: string) { + return this.values.get(key) ?? null; + } + setItem(key: string, value: string) { + this.values.set(key, value); + } + removeItem(key: string) { + this.values.delete(key); + } +} + +const originals = Object.fromEntries( + ["window", "localStorage", "sessionStorage"].map((key) => [ + key, + Object.getOwnPropertyDescriptor(globalThis, key) + ]) +); +const environment = authEnvironment(); +const appLabel = environment === "development" ? "Maple Agent Dev" : "Maple Agent"; +const startUrl = "https://auth.example.test/start"; +const fixtureUser = { id: "agent-fixture-user", email: "agent-fixture@example.test" }; +const nativeTarget = { + provider: "github" as DesktopOAuthProvider, + nativeSessionId: "11".repeat(16), + nativeRequestId: "22".repeat(16), + nativeApp: "agent" as const, + environment, + returnPort: 49231, + returnState: "33".repeat(16) +}; +let sequence = 0; + +function credentials() { + const token = (purpose: "access" | "refresh") => + [ + Buffer.from(JSON.stringify({ alg: "ES256K", typ: "JWT" })).toString("base64url"), + Buffer.from( + JSON.stringify({ + aud: `urn:opensecret:internal:transport-v2:user:${purpose}-token`, + sub: fixtureUser.id, + exp: 2_000_000_000, + tf: 2 + }) + ).toString("base64url"), + Buffer.from(new Uint8Array(64).fill(0x5a)).toString("base64url") + ].join("."); + return { accessToken: token("access"), refreshToken: token("refresh") }; +} + +describe("Agent loopback return with the real SDK credential fence", () => { + let renderer: ReactTestRenderer | null; + let client: OpenSecretContextType; + let local: MemoryStorage; + let target: TransportV2DesktopOAuthState; + let mint: ReturnType; + let now: number; + let expiresAt: number; + let clock: ReturnType>; + + const installCredentials = () => { + const prepared = prepareNativeOAuthHandoff(client.apiUrl); + return installNativeOAuthHandoffCredentials( + client.apiUrl, + credentials(), + prepared.expectedAuth, + fixtureUser.id + ); + }; + + const advanceCredentialRevision = () => { + // Simulate another tab's completed storage write. Keep the installed token pair + // and principal unchanged so the real SDK read must detect the revision alone. + const previous = readNativeUserAuth(client.apiUrl); + const entry = [...local.values].find(([, value]) => { + try { + return JSON.parse(value).api_origin === client.apiUrl; + } catch { + return false; + } + }); + expect(entry).toBeDefined(); + const stored = JSON.parse(entry![1]); + stored.user.revision += 1; + local.setItem(entry![0], JSON.stringify(stored)); + const current = readNativeUserAuth(client.apiUrl); + expect(current.revision).toBe(previous.revision + 1); + expect(current.credentials).toEqual(previous.credentials); + expect(current.principalId).toBe(previous.principalId); + return current; + }; + + beforeEach(() => { + renderer = null; + now = 1_900_000_000_000; + expiresAt = now / 1000 + 60; + clock = spyOn(Date, "now").mockImplementation(() => now); + local = new MemoryStorage(); + const session = new MemoryStorage(); + for (const [key, value] of Object.entries({ + window: { + localStorage: local, + sessionStorage: session, + location: { origin: "https://auth.example.test", href: startUrl } + }, + localStorage: local, + sessionStorage: session + })) + Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + mint = mock(async () => ({ grant: "aaa.bbb.ccc", expires_at: expiresAt })); + client = { + apiUrl: `https://agent-fixture-${++sequence}.example.test`, + auth: { loading: false, user: { user: fixtureUser } }, + mintNativeHandoffGrant: mint + } as unknown as OpenSecretContextType; + installCredentials(); + markTransportV2DesktopOAuth(nativeTarget); + target = readTransportV2DesktopOAuth("github")!; + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + clock.mockRestore(); + for (const [key, descriptor] of Object.entries(originals)) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + }); + + const renderConfirmation = async () => { + await act(async () => { + renderer = create( + + + + ); + }); + }; + + const button = (label: string) => + renderer!.root.findAllByType("button").find((node) => node.children.join("") === label); + const continueButton = () => button(`Continue to ${appLabel}`)!; + const returnButton = () => button(`Return to ${appLabel}`)!; + const approve = async () => { + await act(async () => continueButton().props.onClick()); + }; + const expectCredentialsRetained = (before: [string, string][]) => { + expect([...local.values]).toEqual(before); + expect(readNativeUserAuth(client.apiUrl).principalId).toBe(fixtureUser.id); + }; + + for (const provider of ["github", "google", "apple"] as const) { + test(`${provider} identifies the Agent audience and account before minting`, async () => { + markTransportV2DesktopOAuth({ ...nativeTarget, provider }); + target = readTransportV2DesktopOAuth(provider)!; + await renderConfirmation(); + expect(target.provider).toBe(provider); + expect(continueButton()).toBeDefined(); + expect(returnButton()).toBeUndefined(); + expect(JSON.stringify(renderer!.toJSON())).toContain(fixtureUser.email); + expect(button("Continue to Maple")).toBeUndefined(); + expect(button("Open Maple")).toBeUndefined(); + expect(mint).not.toHaveBeenCalled(); + expect(window.location.href).toBe(startUrl); + }); + } + + test("mints once and waits for a second explicit click before the fixed loopback return", async () => { + const before = [...local.values]; + await renderConfirmation(); + const confirm = continueButton().props.onClick; + await act(async () => { + await Promise.all([confirm(), confirm()]); + }); + expect(mint).toHaveBeenCalledTimes(1); + expect(mint).toHaveBeenCalledWith(nativeTarget.nativeSessionId, nativeTarget.nativeRequestId); + expect(window.location.href).toBe(startUrl); + expect(returnButton()).toBeDefined(); + expect(button("Open Maple")).toBeUndefined(); + expect(readTransportV2DesktopOAuth()).toBeNull(); + act(() => returnButton().props.onClick()); + expect(window.location.href).toBe( + `http://127.0.0.1:${nativeTarget.returnPort}/auth/callback?handoff_grant=aaa.bbb.ccc&return_state=${nativeTarget.returnState}` + ); + expect(mint).toHaveBeenCalledTimes(1); + expectCredentialsRetained(before); + }); + + test("issuer expiry at the exact boundary blocks the manual return even while the native attempt is fresh", async () => { + const before = [...local.values]; + await renderConfirmation(); + await approve(); + now = expiresAt * 1000; + act(() => returnButton().props.onClick()); + expect(window.location.href).toBe(startUrl); + expect(returnButton()).toBeUndefined(); + expect(mint).toHaveBeenCalledTimes(1); + expectCredentialsRetained(before); + }); + + for (const replacement of [{ returnPort: 49232 }, { returnState: "44".repeat(16) }]) { + test(`a newer flow with changed ${Object.keys(replacement)[0]} blocks the old manual return`, async () => { + const before = [...local.values]; + await renderConfirmation(); + await approve(); + markTransportV2DesktopOAuth({ ...nativeTarget, ...replacement }); + const newer = readTransportV2DesktopOAuth(); + act(() => returnButton().props.onClick()); + expect(window.location.href).toBe(startUrl); + expect(returnButton()).toBeUndefined(); + expect(readTransportV2DesktopOAuth()).toEqual(newer); + expectCredentialsRetained(before); + }); + } + + test("a newer credential revision blocks manual return even for the same account", async () => { + await renderConfirmation(); + await approve(); + const previous = readNativeUserAuth(client.apiUrl); + const current = advanceCredentialRevision(); + expect(current.revision).not.toBe(previous.revision); + const before = [...local.values]; + act(() => returnButton().props.onClick()); + expect(window.location.href).toBe(startUrl); + expect(returnButton()).toBeUndefined(); + expectCredentialsRetained(before); + }); + + test("cancellation before confirmation mints nothing and retains browser credentials", async () => { + const before = [...local.values]; + await renderConfirmation(); + act(() => button("Cancel")!.props.onClick()); + expect(mint).not.toHaveBeenCalled(); + expect(readTransportV2DesktopOAuth()).toBeNull(); + expect(window.location.href).toBe(startUrl); + expect(continueButton()).toBeUndefined(); + expect(returnButton()).toBeUndefined(); + expectCredentialsRetained(before); + }); + + test("cancellation after minting discards the return without clearing browser credentials", async () => { + const before = [...local.values]; + await renderConfirmation(); + await approve(); + act(() => button("Cancel")!.props.onClick()); + expect(window.location.href).toBe(startUrl); + expect(returnButton()).toBeUndefined(); + expect(mint).toHaveBeenCalledTimes(1); + expectCredentialsRetained(before); + }); + + for (const invalidation of ["cancel", "replacement", "credentials", "unmount", "expiry"]) { + test(`${invalidation} while minting discards the late grant`, async () => { + let resolveMint!: (value: { grant: string; expires_at: number }) => void; + mint.mockImplementation( + () => + new Promise<{ grant: string; expires_at: number }>((resolve) => { + resolveMint = resolve; + }) + ); + await renderConfirmation(); + let approval!: Promise; + act(() => { + approval = continueButton().props.onClick(); + }); + expect(mint).toHaveBeenCalledTimes(1); + expect(window.location.href).toBe(startUrl); + let newer: TransportV2DesktopOAuthState | null = null; + if (invalidation === "cancel") act(() => button("Cancel")!.props.onClick()); + if (invalidation === "replacement") { + markTransportV2DesktopOAuth({ ...nativeTarget, returnState: "55".repeat(16) }); + newer = readTransportV2DesktopOAuth(); + } + if (invalidation === "credentials") advanceCredentialRevision(); + if (invalidation === "expiry") now += TRANSPORT_V2_PENDING_TTL_MS + 1; + if (invalidation === "unmount") { + await act(async () => renderer!.unmount()); + renderer = null; + } + const before = [...local.values]; + await act(async () => { + resolveMint({ grant: "aaa.bbb.ccc", expires_at: expiresAt }); + await approval; + }); + expect(window.location.href).toBe(startUrl); + expect(mint).toHaveBeenCalledTimes(1); + if (renderer) expect(returnButton()).toBeUndefined(); + if (newer) expect(readTransportV2DesktopOAuth()).toEqual(newer); + else expect(readTransportV2DesktopOAuth()).toBeNull(); + expectCredentialsRetained(before); + }); + } + + test("an already expired grant never becomes a manual return action", async () => { + expiresAt = now / 1000; + const before = [...local.values]; + await renderConfirmation(); + await approve(); + expect(window.location.href).toBe(startUrl); + expect(returnButton()).toBeUndefined(); + expect(readTransportV2DesktopOAuth()).toBeNull(); + expect(mint).toHaveBeenCalledTimes(1); + expectCredentialsRetained(before); + }); +}); diff --git a/apps/maple-auth/src/auth-site/fixtures/AuthSite.case.tsx b/apps/maple-auth/src/auth-site/fixtures/AuthSite.case.tsx index 78ee4e835..c037be6c7 100644 --- a/apps/maple-auth/src/auth-site/fixtures/AuthSite.case.tsx +++ b/apps/maple-auth/src/auth-site/fixtures/AuthSite.case.tsx @@ -431,4 +431,36 @@ describe("hosted authentication entry", () => { expect(readTransportV2DesktopOAuth("github")).toEqual(replacement); }); } + + for (const provider of ["github", "google"] as const) { + test(`keeps the full Agent target through ${provider} initiation and callback`, async () => { + const params = new URLSearchParams({ + provider, + transport: "v2", + native_session_id: nativeSessionId, + native_request_id: nativeRequestId, + return_port: "43123", + return_state: "12".repeat(16) + }); + await renderAt(`${authOrigin}/agent/start?${params}`); + const target = readTransportV2DesktopOAuth(provider); + expect(target).toMatchObject({ + provider, + nativeApp: "agent", + nativeSessionId, + nativeRequestId, + returnPort: 43123, + returnState: "12".repeat(16), + environment: authEnvironment() + }); + expect(window.location.href).toBe(providerUrl); + await act(async () => renderer?.unmount()); + renderer = null; + await renderAt(callbackUrl(provider)); + expect(renderer!.root.findByType(HostedNativeSignInConfirmation).props.target).toEqual( + target + ); + expect(mintNativeHandoffGrant).not.toHaveBeenCalled(); + }); + } }); diff --git a/apps/maple-auth/src/auth-site/route.test.ts b/apps/maple-auth/src/auth-site/route.test.ts index c3bb2bd0f..d3c9d06af 100644 --- a/apps/maple-auth/src/auth-site/route.test.ts +++ b/apps/maple-auth/src/auth-site/route.test.ts @@ -265,3 +265,107 @@ describe("native application profile admission", () => { } }); }); + +describe("Agent loopback admission", () => { + for (const environment of ["development", "production"] as const) { + const agentParams = (provider = "github") => { + const params = startParams(provider); + params.set("return_port", "43123"); + params.set("return_state", "12".repeat(16)); + return params; + }; + const parseAgent = (params: URLSearchParams) => + parseAuthSiteRoute( + { + pathname: "/agent/start", + search: `?${params}` + }, + environment + ); + + for (const provider of providers) { + test(`${environment} ${provider} stores a fixed Agent destination`, () => { + expect(parseAgent(agentParams(provider))).toEqual({ + kind: "start", + nativeApp: "agent", + provider, + nativeSessionId, + nativeRequestId, + returnPort: 43123, + returnState: "12".repeat(16), + environment + }); + }); + } + + test(`${environment} accepts only canonical bounded decimal ports`, () => { + for (const port of ["1", "65535"]) { + const params = agentParams(); + params.set("return_port", port); + expect(parseAgent(params)).toMatchObject({ kind: "start", returnPort: Number(port) }); + } + for (const port of [ + "", + "0", + "65536", + "01", + "0043", + "-1", + "+1", + "80.0", + "8e1", + "0x50", + "80 ", + "80\n", + "127.0.0.1:80" + ]) { + const params = agentParams(); + params.set("return_port", port); + expect(parseAgent(params)).toEqual({ kind: "invalid" }); + } + }); + + test(`${environment} rejects ambiguous fields, foreign targets and query-selected environments`, () => { + for (const key of agentParams().keys()) { + const missing = agentParams(); + missing.delete(key); + expect(parseAgent(missing)).toEqual({ kind: "invalid" }); + const duplicate = agentParams(); + duplicate.append(key, duplicate.get(key)!); + expect(parseAgent(duplicate)).toEqual({ kind: "invalid" }); + } + for (const state of [ + "", + "AA".repeat(16), + "12".repeat(15), + "12".repeat(17), + "xyz", + "12".repeat(16) + " " + ]) { + const params = agentParams(); + params.set("return_state", state); + expect(parseAgent(params)).toEqual({ kind: "invalid" }); + } + for (const key of [ + "return_url", + "return_host", + "return_path", + "environment", + "api_url", + "native_app_variant", + "native_app" + ]) { + const params = agentParams(); + params.set(key, "dev"); + expect(parseAgent(params)).toEqual({ kind: "invalid" }); + } + const researchParams = agentParams(); + if (environment === "development") researchParams.set("native_app_variant", "dev"); + for (const pathname of ["/start", "/desktop-auth", "/agent/start/", "/agent"]) { + expect(parseAuthSiteRoute({ pathname, search: `?${researchParams}` }, environment)).toEqual( + { kind: "invalid" } + ); + } + }); + } +}); diff --git a/apps/maple-auth/src/auth-site/route.ts b/apps/maple-auth/src/auth-site/route.ts index be05aef7b..aebb9987c 100644 --- a/apps/maple-auth/src/auth-site/route.ts +++ b/apps/maple-auth/src/auth-site/route.ts @@ -1,24 +1,31 @@ -import { isTransportV2PublicId, type DesktopOAuthProvider } from "@/services/desktopOAuthTransport"; +import { + isAgentReturnPort, + isTransportV2PublicId, + type DesktopOAuthProvider, + type NativeOAuthInput +} from "@/services/desktopOAuthTransport"; import { authEnvironment, isNativeAppVariantAllowed, - type AuthEnvironment, - type NativeAppVariant + type AuthEnvironment } from "@/config/authEnvironment"; export type AuthSiteRoute = - | { - kind: "start"; - provider: DesktopOAuthProvider; - nativeSessionId: string; - nativeRequestId: string; - nativeAppVariant?: NativeAppVariant; - } + | ({ kind: "start" } & NativeOAuthInput) | { kind: "callback"; provider: DesktopOAuthProvider; code: string; state: string } | { kind: "complete" } | { kind: "invalid" }; +const AGENT_START_PARAMETERS = new Set([ + "transport", + "provider", + "native_session_id", + "native_request_id", + "return_port", + "return_state" +]); + const START_PARAMETERS = new Set([ "transport", "provider", @@ -42,7 +49,12 @@ export function parseAuthSiteRoute( environment: AuthEnvironment = authEnvironment() ): AuthSiteRoute { const params = new URLSearchParams(location.search); - if (location.pathname === "/start" || location.pathname === "/desktop-auth") { + if ( + location.pathname === "/start" || + location.pathname === "/desktop-auth" || + location.pathname === "/agent/start" + ) { + const agent = location.pathname === "/agent/start"; const provider = singleParameter(params, "provider"); const nativeSessionId = singleParameter(params, "native_session_id"); const nativeRequestId = singleParameter(params, "native_request_id"); @@ -50,15 +62,38 @@ export function parseAuthSiteRoute( ? singleParameter(params, "native_app_variant") : undefined; if ( - [...params.keys()].some((key) => !START_PARAMETERS.has(key)) || + [...params.keys()].some( + (key) => !(agent ? AGENT_START_PARAMETERS : START_PARAMETERS).has(key) + ) || singleParameter(params, "transport") !== "v2" || (provider !== "github" && provider !== "google" && provider !== "apple") || !isTransportV2PublicId(nativeSessionId) || !isTransportV2PublicId(nativeRequestId) || - !isNativeAppVariantAllowed(nativeAppVariant, environment) + (!agent && !isNativeAppVariantAllowed(nativeAppVariant, environment)) ) { return { kind: "invalid" }; } + if (agent) { + const port = singleParameter(params, "return_port"); + const returnState = singleParameter(params, "return_state"); + if ( + !port || + !/^[1-9][0-9]{0,4}$/u.test(port) || + !isAgentReturnPort(Number(port)) || + !isTransportV2PublicId(returnState) + ) + return { kind: "invalid" }; + return { + kind: "start", + provider, + nativeSessionId, + nativeRequestId, + nativeApp: "agent", + returnPort: Number(port), + returnState, + environment + }; + } return { kind: "start", provider, diff --git a/apps/maple-auth/src/auth-site/ui.test.ts b/apps/maple-auth/src/auth-site/ui.test.ts index ab8d96997..1558c5a67 100644 --- a/apps/maple-auth/src/auth-site/ui.test.ts +++ b/apps/maple-auth/src/auth-site/ui.test.ts @@ -6,7 +6,7 @@ const appDirectory = fileURLToPath(new URL("../..", import.meta.url)); describe("hosted auth UI with the real SDK context", () => { for (const environment of ["production", "development"]) { for (const [fixture, cases] of [ - ["AuthSite", 25], + ["AuthSite", 27], ["HostedAppleSignIn", 15] ] as const) { test(`${environment} ${fixture} runs every case without shared module mocks`, () => { diff --git a/apps/maple-auth/src/components/HostedNativeSignInConfirmation.tsx b/apps/maple-auth/src/components/HostedNativeSignInConfirmation.tsx index e2e3cb1f2..f2cbe34c5 100644 --- a/apps/maple-auth/src/components/HostedNativeSignInConfirmation.tsx +++ b/apps/maple-auth/src/components/HostedNativeSignInConfirmation.tsx @@ -1,12 +1,14 @@ import { useEffect, useRef, useState } from "react"; import { readNativeUserAuth, useOpenSecret } from "@mapleai/sdk"; import { Button } from "@/components/ui/button"; -import { isNativeAppVariantAllowed } from "@/config/authEnvironment"; import { clearDesktopOAuthTarget, isCurrentDesktopOAuthTarget, isNativeOAuthRedirect, - mintTransportV2NativeAuthDeepLink, + isNativeTargetAllowed, + mintTransportV2NativeAuthReturn, + nativeAppLabel, + type NativeAuthReturn, TRANSPORT_V2_PENDING_TTL_MS, type TransportV2DesktopOAuthState } from "@/services/desktopOAuthTransport"; @@ -36,15 +38,12 @@ export function HostedNativeSignInConfirmation({ }); const [status, setStatus] = useState<"confirm" | "minting" | "complete" | "closed">("confirm"); const [message, setMessage] = useState(null); - const [deepLink, setDeepLink] = useState(null); + const [handoff, setHandoff] = useState(null); + const appLabel = nativeAppLabel(target); + const isAgent = target.nativeApp === "agent"; const ownsAccount = () => { - if ( - !active.current || - cancelled.current || - !account || - !isNativeAppVariantAllowed(target.nativeAppVariant) - ) + if (!active.current || cancelled.current || !account || !isNativeTargetAllowed(target)) return false; const current = currentOs.current; if (current.apiUrl !== account.apiUrl || current.auth.user?.user.id !== account.id) @@ -63,8 +62,8 @@ export function HostedNativeSignInConfirmation({ () => { submitted.current = true; clearDesktopOAuthTarget(target); - setDeepLink(null); - setMessage("This sign-in expired. Start a new login in Maple."); + setHandoff(null); + setMessage(`This sign-in expired. Start a new login in ${appLabel}.`); setStatus("closed"); }, Math.max(0, target.startedAt + TRANSPORT_V2_PENDING_TTL_MS - Date.now()) @@ -77,14 +76,27 @@ export function HostedNativeSignInConfirmation({ if (!active.current) clearDesktopOAuthTarget(target); }); }; - }, [target]); + }, [target, appLabel]); + + useEffect(() => { + if (handoff?.expiresAt === undefined) return; + const timer = setTimeout( + () => { + setHandoff(null); + setMessage(`This sign-in expired. Start a new login in ${appLabel}.`); + setStatus("closed"); + }, + Math.max(0, handoff.expiresAt - Date.now()) + ); + return () => clearTimeout(timer); + }, [handoff, appLabel]); const cancel = () => { submitted.current = true; cancelled.current = true; clearDesktopOAuthTarget(target); - setDeepLink(null); - setMessage("Sign-in cancelled. You can close this page and return to Maple."); + setHandoff(null); + setMessage(`Sign-in cancelled. You can close this page and return to ${appLabel}.`); setStatus("closed"); }; @@ -93,45 +105,51 @@ export function HostedNativeSignInConfirmation({ submitted.current = true; setStatus("minting"); try { - const url = await mintTransportV2NativeAuthDeepLink( + const result = await mintTransportV2NativeAuthReturn( target, os.mintNativeHandoffGrant, ownsAccount ); if (!ownsAccount()) return; - setDeepLink(url); + setHandoff(result); setStatus("complete"); - window.location.href = url; + // Loopback return must be a separate top-level navigation in a user click. + if (!isAgent) window.location.href = result.url; } catch { if (!active.current || cancelled.current) return; - setMessage("This sign-in could not be completed. Start a new login in Maple."); + setMessage(`This sign-in could not be completed. Start a new login in ${appLabel}.`); setStatus("closed"); } }; const openMaple = () => { // The target was consumed after minting; a new pending flow invalidates this fallback. - if (!deepLink || !ownsAccount() || isNativeOAuthRedirect()) { + if ( + !handoff || + !ownsAccount() || + isNativeOAuthRedirect() || + (handoff.expiresAt !== undefined && Date.now() >= handoff.expiresAt) + ) { cancel(); return; } - window.location.href = deepLink; + window.location.href = handoff.url; }; if (!account) { - return

Your account could not be verified. Start a new login in Maple.

; + return

Your account could not be verified. Start a new login in {appLabel}.

; } if (status === "closed") return

{message}

; return (
-

Sign in to the Maple app as

+

Sign in to {isAgent ? appLabel : "the Maple app"} as

{account.email || `Account ${account.id}`}

- Continue only if you started this login in Maple. Check that Maple shows the same account - before signing in there. + Continue only if you started this login in {appLabel}. + {!isAgent && " Check that Maple shows the same account before signing in there."}

{status === "complete" ? ( ) : ( )}
diff --git a/apps/maple-auth/src/services/desktopOAuthTransport.test.ts b/apps/maple-auth/src/services/desktopOAuthTransport.test.ts index 972935ea6..52bb643a9 100644 --- a/apps/maple-auth/src/services/desktopOAuthTransport.test.ts +++ b/apps/maple-auth/src/services/desktopOAuthTransport.test.ts @@ -5,8 +5,10 @@ import { claimTransportV2DesktopOAuthInitiation, clearDesktopOAuthTarget, isNativeOAuthRedirect, + isCurrentDesktopOAuthTarget, + type NativeOAuthInput, markTransportV2DesktopOAuth, - mintTransportV2NativeAuthDeepLink, + mintTransportV2NativeAuthReturn, readTransportV2DesktopOAuth, type TransportV2DesktopOAuthState } from "./desktopOAuthTransport"; @@ -149,7 +151,7 @@ for (const environment of ["production", "development"] as const) { markTransportV2DesktopOAuth(state, 1_000); const calls: string[][] = []; - const deepLink = await mintTransportV2NativeAuthDeepLink( + const deepLink = await mintTransportV2NativeAuthReturn( { ...state, startedAt: 1_000 }, async (sessionId, requestId) => { calls.push([sessionId, requestId]); @@ -160,7 +162,7 @@ for (const environment of ["production", "development"] as const) { ); expect(calls).toEqual([[nativeSessionId, nativeRequestId]]); - expect(new URL(deepLink).search).toBe("?handoff_grant=head.payload.c2ln"); + expect(new URL(deepLink.url).search).toBe("?handoff_grant=head.payload.c2ln"); expect(readTransportV2DesktopOAuth("github", 1_002)).toBeNull(); expect(isNativeOAuthRedirect()).toBe(false); }); @@ -170,7 +172,7 @@ for (const environment of ["production", "development"] as const) { let calls = 0; await expect( - mintTransportV2NativeAuthDeepLink( + mintTransportV2NativeAuthReturn( { ...state, provider: "google", startedAt: 1_000 }, async () => { calls += 1; @@ -194,14 +196,14 @@ for (const environment of ["production", "development"] as const) { resolve = done; }); }; - const first = mintTransportV2NativeAuthDeepLink( + const first = mintTransportV2NativeAuthReturn( target, mint, () => true, () => 1_001 ); await expect( - mintTransportV2NativeAuthDeepLink( + mintTransportV2NativeAuthReturn( target, mint, () => true, @@ -220,7 +222,7 @@ for (const environment of ["production", "development"] as const) { let resolve!: (result: { grant: string }) => void; let ownsAccount = true; let now = 1_001; - const pending = mintTransportV2NativeAuthDeepLink( + const pending = mintTransportV2NativeAuthReturn( target, () => new Promise<{ grant: string }>((done) => { @@ -255,7 +257,7 @@ for (const environment of ["production", "development"] as const) { throw new Error("network lost"); }; await expect( - mintTransportV2NativeAuthDeepLink( + mintTransportV2NativeAuthReturn( target, mint, () => true, @@ -263,7 +265,7 @@ for (const environment of ["production", "development"] as const) { ) ).rejects.toThrow("network lost"); await expect( - mintTransportV2NativeAuthDeepLink( + mintTransportV2NativeAuthReturn( target, mint, () => true, @@ -296,7 +298,7 @@ for (const environment of ["production", "development"] as const) { sessionStorage.setItem("maple_desktop_oauth_pending_v2", JSON.stringify(target)); let calls = 0; await expect( - mintTransportV2NativeAuthDeepLink( + mintTransportV2NativeAuthReturn( target, async () => { calls += 1; @@ -321,5 +323,162 @@ for (const environment of ["production", "development"] as const) { ).toThrow("does not match"); expect(readTransportV2DesktopOAuth("github", 1_001)).toEqual({ ...state, startedAt: 1_000 }); }); + + const agent: NativeOAuthInput = { + provider: "github", + nativeSessionId, + nativeRequestId, + nativeApp: "agent", + returnPort: 43123, + returnState: "12".repeat(16), + environment + }; + + test("persists the complete Agent target and claims it only once", () => { + markTransportV2DesktopOAuth(agent, 1_000); + expect(readTransportV2DesktopOAuth("github", 1_001)).toEqual({ ...agent, startedAt: 1_000 }); + expect(claimTransportV2DesktopOAuthInitiation(agent, 1_001)).toBe(true); + markTransportV2DesktopOAuth(agent, 1_002); + expect(claimTransportV2DesktopOAuthInitiation(agent, 1_003)).toBe(false); + expect(readTransportV2DesktopOAuth("github", 1_003)?.startedAt).toBe(1_000); + }); + + test("returns only the fixed Agent URL and caps it at issuer expiry", async () => { + markTransportV2DesktopOAuth(agent, 1_000); + const target = readTransportV2DesktopOAuth("github", 1_001)!; + localStorage.setItem("fixture-credentials", "retained"); + const calls: string[][] = []; + const result = await mintTransportV2NativeAuthReturn( + target, + async (...pair) => { + calls.push(pair); + return { grant: "head.payload.c2ln", expires_at: 61 }; + }, + () => true, + () => 1_001 + ); + expect(result).toEqual({ + url: + "http://127.0.0.1:43123/auth/callback?handoff_grant=head.payload.c2ln&return_state=" + + "12".repeat(16), + expiresAt: 61_000 + }); + expect(calls).toEqual([[nativeSessionId, nativeRequestId]]); + expect(readTransportV2DesktopOAuth("github", 1_002)).toBeNull(); + expect(localStorage.getItem("fixture-credentials")).toBe("retained"); + }); + + test("keeps an explicit port throughout the accepted range", async () => { + for (const returnPort of [1, 80, 65535]) { + const input = { ...agent, returnPort }; + markTransportV2DesktopOAuth(input, 1_000); + const result = await mintTransportV2NativeAuthReturn( + { ...input, startedAt: 1_000 }, + async () => ({ + grant: "head.payload.c2ln", + expires_at: 61 + }), + () => true, + () => 1_001 + ); + expect(result.url).toBe( + `http://127.0.0.1:${returnPort}/auth/callback?handoff_grant=head.payload.c2ln&return_state=${agent.returnState}` + ); + } + }); + + test("caps Agent return at the pending attempt's deadline", async () => { + markTransportV2DesktopOAuth(agent, 1_000); + const result = await mintTransportV2NativeAuthReturn( + { ...agent, startedAt: 1_000 }, + async () => ({ + grant: "head.payload.c2ln", + expires_at: 9_000 + }), + () => true, + () => 1_001 + ); + expect(result.expiresAt).toBe(1_000 + TRANSPORT_V2_PENDING_TTL_MS); + }); + + for (const replacement of [ + { ...agent, returnPort: 43124 }, + { ...agent, returnState: "34".repeat(16) }, + state + ] as NativeOAuthInput[]) { + test(`rejects a late mint after replacing Agent with ${replacement.nativeApp === "agent" ? replacement.returnPort + ":" + replacement.returnState : "Research"}`, async () => { + markTransportV2DesktopOAuth(agent, 1_000); + const target = readTransportV2DesktopOAuth("github", 1_001)!; + let resolve!: (value: { grant: string; expires_at: number }) => void; + const mint = mintTransportV2NativeAuthReturn( + target, + () => + new Promise((done) => { + resolve = done; + }), + () => true, + () => 1_003 + ); + markTransportV2DesktopOAuth(replacement, 1_002); + expect(isCurrentDesktopOAuthTarget(target, 1_003)).toBe(false); + expect(() => claimTransportV2DesktopOAuthInitiation(agent, 1_003)).toThrow("state changed"); + clearDesktopOAuthTarget(target); + resolve({ grant: "head.payload.c2ln", expires_at: 61 }); + await expect(mint).rejects.toThrow("changed or expired"); + expect(readTransportV2DesktopOAuth("github", 1_004)).toEqual({ + ...replacement, + startedAt: 1_002 + }); + }); + } + + test("rejects malformed, mixed-app and wrong-environment stored Agent targets", () => { + for (const alteration of [ + { returnPort: 0 }, + { returnPort: 65536 }, + { returnPort: "43123" }, + { returnPort: 1.5 }, + { returnState: "AA".repeat(16) }, + { returnState: "12" }, + { nativeApp: "unknown" }, + { nativeApp: undefined }, + { nativeAppVariant: "dev" }, + { environment: environment === "development" ? "production" : "development" } + ]) { + sessionStorage.setItem( + "maple_desktop_oauth_pending_v2", + JSON.stringify({ ...agent, startedAt: 1_000, ...alteration }) + ); + expect(readTransportV2DesktopOAuth("github", 1_001)).toBeNull(); + } + }); + + test("does not expose an Agent return without a live safe issuer expiry", async () => { + for (const expiry of [ + undefined, + 0, + 1, + 1.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + ]) { + markTransportV2DesktopOAuth(agent, 1_000); + let calls = 0; + await expect( + mintTransportV2NativeAuthReturn( + { ...agent, startedAt: 1_000 }, + async () => { + calls += 1; + return { grant: "head.payload.c2ln", expires_at: expiry }; + }, + () => true, + () => 1_001 + ) + ).rejects.toThrow(); + expect(calls).toBe(1); + expect(readTransportV2DesktopOAuth("github", 1_002)).toBeNull(); + } + }); }); } diff --git a/apps/maple-auth/src/services/desktopOAuthTransport.ts b/apps/maple-auth/src/services/desktopOAuthTransport.ts index ae01163d9..ead11b39d 100644 --- a/apps/maple-auth/src/services/desktopOAuthTransport.ts +++ b/apps/maple-auth/src/services/desktopOAuthTransport.ts @@ -1,5 +1,7 @@ import { + authEnvironment, isNativeAppVariantAllowed, + type AuthEnvironment, nativeAuthReturnUrl, type NativeAppVariant } from "@/config/authEnvironment"; @@ -20,18 +22,65 @@ const TRANSPORT_V2_ID_PATTERN = /^[0-9a-f]{32}$/u; const COMPACT_GRANT_PATTERN = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/u; const MAX_HANDOFF_GRANT_LENGTH = 4096; -export interface TransportV2DesktopOAuthState { +type NativeDestination = + | { + nativeApp?: undefined; + nativeAppVariant?: NativeAppVariant; + returnPort?: never; + returnState?: never; + environment?: never; + } + | { + nativeApp: "agent"; + nativeAppVariant?: never; + returnPort: number; + returnState: string; + environment: AuthEnvironment; + }; + +export type NativeOAuthInput = NativeDestination & { provider: DesktopOAuthProvider; nativeSessionId: string; nativeRequestId: string; - nativeAppVariant?: NativeAppVariant; - startedAt: number; -} +}; + +export type TransportV2DesktopOAuthState = NativeOAuthInput & { startedAt: number }; +export type NativeAuthReturn = { url: string; expiresAt?: number }; type NativeHandoffGrantIssuer = ( nativeSessionId: string, nativeRequestId: string -) => Promise<{ grant: string }>; +) => Promise<{ grant: string; expires_at?: number }>; + +export function isAgentReturnPort(value: unknown): value is number { + return typeof value === "number" && Number.isInteger(value) && value >= 1 && value <= 65535; +} + +/** Stored destinations are untrusted and must still match this compiled environment. */ +export function isNativeTargetAllowed(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + const target = value as Record; + if (target.nativeApp === "agent") { + return ( + target.nativeAppVariant === undefined && + target.environment === authEnvironment() && + isAgentReturnPort(target.returnPort) && + isTransportV2PublicId(target.returnState) + ); + } + return ( + target.nativeApp === undefined && + target.returnPort === undefined && + target.returnState === undefined && + target.environment === undefined && + isNativeAppVariantAllowed(target.nativeAppVariant) + ); +} + +export function nativeAppLabel(target: TransportV2DesktopOAuthState): string { + if (target.nativeApp !== "agent") return "Maple"; + return authEnvironment() === "development" ? "Maple Agent Dev" : "Maple Agent"; +} function isDesktopOAuthProvider(value: unknown): value is DesktopOAuthProvider { return value === "github" || value === "google" || value === "apple"; @@ -47,8 +96,11 @@ function assertTransportV2PublicId(value: unknown, label: string): asserts value } } -function pendingClaim(state: TransportV2DesktopOAuthState): string { - return `${state.provider}:${state.nativeSessionId}:${state.nativeRequestId}${state.nativeAppVariant ? `:${state.nativeAppVariant}` : ""}`; +function pendingClaim(state: NativeOAuthInput): string { + const identity = `${state.provider}:${state.nativeSessionId}:${state.nativeRequestId}`; + return state.nativeApp === "agent" + ? `${identity}:agent:${state.environment}:${state.returnPort}:${state.returnState}` + : `${identity}${state.nativeAppVariant ? `:${state.nativeAppVariant}` : ""}`; } function hasValidTimestamp(startedAt: unknown, now: number): startedAt is number { @@ -72,14 +124,11 @@ function removeTransportV2PendingState(): void { sessionStorage.removeItem(TRANSPORT_V2_MINT_CLAIM_KEY); } -export function markTransportV2DesktopOAuth( - state: Omit, - now = Date.now() -): void { +export function markTransportV2DesktopOAuth(state: NativeOAuthInput, now = Date.now()): void { if (!isDesktopOAuthProvider(state.provider)) { throw new Error("Desktop authentication provider is missing or invalid"); } - if (!isNativeAppVariantAllowed(state.nativeAppVariant)) { + if (!isNativeTargetAllowed(state)) { throw new Error("Native application does not match this authentication environment"); } assertTransportV2PublicId(state.nativeSessionId, "native session"); @@ -91,10 +140,7 @@ export function markTransportV2DesktopOAuth( const existing = readTransportV2DesktopOAuth(undefined, now); const nextState: TransportV2DesktopOAuthState = { ...state, - startedAt: - existing && pendingClaim(existing) === pendingClaim({ ...state, startedAt: now }) - ? existing.startedAt - : now + startedAt: existing && pendingClaim(existing) === pendingClaim(state) ? existing.startedAt : now }; if (!existing || pendingClaim(existing) !== pendingClaim(nextState)) { @@ -117,7 +163,7 @@ export function readTransportV2DesktopOAuth( const parsed = JSON.parse(encoded) as Partial; if ( !isDesktopOAuthProvider(parsed.provider) || - !isNativeAppVariantAllowed(parsed.nativeAppVariant) || + !isNativeTargetAllowed(parsed) || !isTransportV2PublicId(parsed.nativeSessionId) || !isTransportV2PublicId(parsed.nativeRequestId) || !hasValidTimestamp(parsed.startedAt, now) @@ -134,16 +180,11 @@ export function readTransportV2DesktopOAuth( } export function claimTransportV2DesktopOAuthInitiation( - expected: Omit, + expected: NativeOAuthInput, now = Date.now() ): boolean { const current = readTransportV2DesktopOAuth(expected.provider, now); - if ( - !current || - current.nativeSessionId !== expected.nativeSessionId || - current.nativeRequestId !== expected.nativeRequestId || - current.nativeAppVariant !== expected.nativeAppVariant - ) { + if (!current || pendingClaim(current) !== pendingClaim(expected)) { throw new Error("Desktop authentication state changed before initiation"); } @@ -162,10 +203,7 @@ export function isNativeOAuthRedirect(): boolean { ); } -export function buildTransportV2NativeAuthDeepLink( - handoffGrant: string, - nativeAppVariant?: NativeAppVariant -): string { +function assertHandoffGrant(handoffGrant: string): void { const grantSegments = handoffGrant.split("."); if ( handoffGrant.length === 0 || @@ -176,7 +214,13 @@ export function buildTransportV2NativeAuthDeepLink( ) { throw new Error("The desktop authentication grant is missing or invalid"); } +} +export function buildTransportV2NativeAuthDeepLink( + handoffGrant: string, + nativeAppVariant?: NativeAppVariant +): string { + assertHandoffGrant(handoffGrant); const deepLink = new URL(nativeAuthReturnUrl(nativeAppVariant)); deepLink.searchParams.set("handoff_grant", handoffGrant); return deepLink.toString(); @@ -211,12 +255,12 @@ export function clearDesktopOAuthTarget(expected: TransportV2DesktopOAuthState): sessionStorage.removeItem(REDIRECT_TO_NATIVE_KEY); } -export async function mintTransportV2NativeAuthDeepLink( +export async function mintTransportV2NativeAuthReturn( handoffTarget: TransportV2DesktopOAuthState, mintGrant: NativeHandoffGrantIssuer, ownsConfirmation: () => boolean, now: () => number = Date.now -): Promise { +): Promise { if (!isCurrentDesktopOAuthTarget(handoffTarget, now()) || !ownsConfirmation()) { throw new Error("Native sign-in changed or expired; please restart login in Maple."); } @@ -227,11 +271,39 @@ export async function mintTransportV2NativeAuthDeepLink( } sessionStorage.setItem(TRANSPORT_V2_MINT_CLAIM_KEY, claim); try { - const { grant } = await mintGrant(handoffTarget.nativeSessionId, handoffTarget.nativeRequestId); + const { grant, expires_at } = await mintGrant( + handoffTarget.nativeSessionId, + handoffTarget.nativeRequestId + ); if (!isCurrentDesktopOAuthTarget(handoffTarget, now()) || !ownsConfirmation()) { throw new Error("Native sign-in changed or expired; please restart login in Maple."); } - return buildTransportV2NativeAuthDeepLink(grant, handoffTarget.nativeAppVariant); + if (handoffTarget.nativeApp === "agent") { + assertHandoffGrant(grant); + // The SDK reports Unix seconds. Cap the manual return at both the grant + // expiry and the pending attempt's deadline; never persist its URL. + if ( + typeof expires_at !== "number" || + !Number.isSafeInteger(expires_at) || + !Number.isSafeInteger(expires_at * 1000) + ) { + throw new Error("Native authentication grant has no valid expiry"); + } + const expiresAt = Math.min( + expires_at * 1000, + handoffTarget.startedAt + TRANSPORT_V2_PENDING_TTL_MS + ); + if (expiresAt <= now()) throw new Error("Native authentication grant expired"); + const query = new URLSearchParams({ + handoff_grant: grant, + return_state: handoffTarget.returnState + }); + return { + url: `http://127.0.0.1:${handoffTarget.returnPort}/auth/callback?${query}`, + expiresAt + }; + } + return { url: buildTransportV2NativeAuthDeepLink(grant, handoffTarget.nativeAppVariant) }; } finally { clearDesktopOAuthTarget(handoffTarget); } diff --git a/apps/maple-research/README.md b/apps/maple-research/README.md index 37abe9164..27c0c8416 100644 --- a/apps/maple-research/README.md +++ b/apps/maple-research/README.md @@ -74,6 +74,11 @@ just desktop-dev # Tauri desktop, including Agent Mode and native features pinned ONNX Runtime and applies a local Tauri configuration overlay when one is present. +For a separately installed macOS package targeting the standalone Auth Dev site, +use the [Research Dev desktop package](docs/research-dev-desktop.md). It has its +own bundle identity, callback scheme, storage, and disabled updater; the ordinary +Local workflow and iOS Dev TestFlight profile remain separate. + ## API configuration `apps/maple-research/frontend/.env.example` documents Maple's public configuration surface: diff --git a/apps/maple-research/docs/research-dev-desktop.md b/apps/maple-research/docs/research-dev-desktop.md new file mode 100644 index 000000000..3fcabe78b --- /dev/null +++ b/apps/maple-research/docs/research-dev-desktop.md @@ -0,0 +1,84 @@ +# Research Dev desktop package + +This manual macOS package rehearses Research's native OAuth handoff through the +standalone Auth Dev site. It is separate from the ordinary `just desktop-dev` +Local workflow and from the existing iOS Dev TestFlight build. + +## Fixed profile + +| Setting | Research Dev desktop | +| --- | --- | +| Display name | Maple Research Dev | +| Bundle ID and callback scheme | `cloud.opensecret.maple.dev` | +| OpenSecret API | `https://enclave.secretgpt.ai` | +| PCR environment | `development` | +| Client ID | `ba5a14b5-d915-47b1-b7b1-afda52bc5fc6` | +| Browser sign-in origin | `https://auth-dev.maple.ai` | +| Flags / billing | `https://flags-dev.opensecret.cloud` / `https://billing-dev.opensecret.cloud` | +| Updater | Disabled; install a new Dev package manually | + +The source of these inputs is +[`desktop-dev-profile.json`](../frontend/src-tauri/desktop-dev-profile.json). +The build script overwrites environment selection without editing ignored env +files. The Rust build rejects a mismatched renderer environment, bundle identity, +callback scheme, or updater configuration. The TypeScript SDK pin and production +profile are unchanged. + +## Build locally + +From the repository root on macOS, with the pinned Xcode installed: + +```bash +nix develop --no-update-lock-file .#ci -c ./scripts/ci/research-dev-desktop.sh +``` + +This builds a debug, host-architecture `.app`, without signing, notarization, +upload, a new release lane, or updater artifacts. It verifies the actual bundle's +identifier, display name, and registered scheme and records the executable hash +and frontend tree hash: + +```text +apps/maple-research/frontend/src-tauri/target/debug/bundle/macos/Maple Research Dev.app +apps/maple-research/frontend/src-tauri/target/research-dev/build-profile.json +apps/maple-research/frontend/src-tauri/target/research-dev/maple-research-dev-macos.tar.gz +``` + +The unprivileged Desktop App PR Build workflow runs this same script as a +separate macOS job and uploads `maple-research-dev-macos-pr` for review. That +artifact is a local-install rehearsal package, not a signed release or deployment. + +The different bundle ID separates the WebView's stored credentials and Tauri's +app config/data directories from the production app. Dev does not run the old +production-directory cleanup or inherit access to `$HOME/.config/maple`. +Automatic and manual updater commands are disabled in native code, including +installation/restart commands; changing a renderer preference cannot enable them. + +## OAuth contract and rehearsal + +The package reuses the existing Dev variant's native handoff and emits: + +```text +https://auth-dev.maple.ai/desktop-auth?provider=&transport=v2&native_session_id=&native_request_id=&native_app_variant=dev +``` + +The return contains only the one-use grant and uses +`cloud.opensecret.maple.dev://auth`. A Dev build ignores production callback +schemes, and production ignores the Dev scheme. The Dev handler reads a buffered +launch URL after subscribing to subsequent callbacks; both paths use the same +pending-attempt and account-confirmation checks. A process that exited has lost +its in-memory native attempt, so an unsolicited cold launch cannot sign it in: +start a new sign-in from the app. Duplicate delivery cannot install twice. + +Before real provider tests, Auth Dev must be published with the matching Dev +backend/client, additive callback settings and provider registrations. Building +this package does not configure or deploy those services. The current iOS Dev +`MAPLE_IOS_DEV_AUTH_ORIGIN` and Research's built-in web auth flow remain unchanged; +this package does not require moving the existing App Dev hostname. + +Validate the installed Dev package alongside production: check the correct app +opens for each scheme, exercise an already-running return and startup delivery, +verify automatic and manual opening, reject an unsolicited/other-app return, +and confirm production state and updater remain separate. Then test all intended +providers and same-account/different-account confirmation on the real Auth Dev +host. Source tests, package identity verification, OS dispatch, and successful +provider sign-in are separate evidence; none substitutes for the others. diff --git a/apps/maple-research/frontend/src-tauri/Cargo.toml b/apps/maple-research/frontend/src-tauri/Cargo.toml index c98445bde..8ef637a07 100644 --- a/apps/maple-research/frontend/src-tauri/Cargo.toml +++ b/apps/maple-research/frontend/src-tauri/Cargo.toml @@ -17,6 +17,7 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2.6.2", features = [] } plist = "1" +serde_json = "1.0" [dependencies] serde_json = "1.0" diff --git a/apps/maple-research/frontend/src-tauri/build.rs b/apps/maple-research/frontend/src-tauri/build.rs index a5131d488..2af7b2cae 100644 --- a/apps/maple-research/frontend/src-tauri/build.rs +++ b/apps/maple-research/frontend/src-tauri/build.rs @@ -2,6 +2,7 @@ mod ios_app_variant; fn main() { + validate_desktop_dev_profile(); println!("cargo:rerun-if-env-changed=VITE_OPEN_SECRET_PCR_ENVIRONMENT"); println!("cargo:rerun-if-env-changed=MAPLE_IOS_VARIANT"); println!("cargo:rerun-if-env-changed=VITE_MAPLE_APP_VARIANT"); @@ -28,6 +29,58 @@ fn main() { } } +// A standalone Dev package must not silently mix the production native identity +// with a Dev renderer (or inherit an operator's iOS/TestFlight origin). +fn validate_desktop_dev_profile() { + println!("cargo:rerun-if-env-changed=MAPLE_DESKTOP_VARIANT"); + println!("cargo:rerun-if-env-changed=VITE_MAPLE_DESKTOP_VARIANT"); + println!("cargo:rerun-if-env-changed=TAURI_CONFIG"); + println!("cargo:rerun-if-changed=desktop-dev-profile.json"); + let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default(); + let config: serde_json::Value = std::env::var("TAURI_CONFIG") + .ok() + .map(|value| serde_json::from_str(&value).expect("invalid Tauri configuration overlay")) + .unwrap_or_default(); + if std::env::var("MAPLE_DESKTOP_VARIANT").is_err() { + assert!( + std::env::var("VITE_MAPLE_DESKTOP_VARIANT").is_err(), + "use the Research Dev desktop build script" + ); + if target_os == "macos" { + assert_ne!( + config["identifier"], "cloud.opensecret.maple.dev", + "use the Research Dev desktop build script for this bundle" + ); + } + return; + } + assert_eq!( + target_os, "macos", + "Research Dev desktop packaging currently supports macOS" + ); + let profile: serde_json::Value = + serde_json::from_str(include_str!("desktop-dev-profile.json")).unwrap(); + for (key, expected) in profile["environment"].as_object().unwrap() { + println!("cargo:rerun-if-env-changed={key}"); + assert_eq!( + std::env::var(key).ok().as_deref(), + expected.as_str(), + "Research Dev desktop environment mismatch: {key}" + ); + } + assert_eq!(config["identifier"], profile["identifier"]); + assert_eq!(config["productName"], profile["productName"]); + assert_eq!( + config["plugins"]["deep-link"]["desktop"]["schemes"], + serde_json::json!([profile["scheme"]]) + ); + assert_eq!( + config["plugins"]["updater"]["endpoints"], + serde_json::json!([]) + ); + assert_eq!(config["bundle"]["createUpdaterArtifacts"], false); +} + #[allow(dead_code)] fn ensure_ios_custom_url_scheme() { let plist_path = std::path::Path::new("gen/apple/maple_iOS/Info.plist"); diff --git a/apps/maple-research/frontend/src-tauri/desktop-dev-profile.json b/apps/maple-research/frontend/src-tauri/desktop-dev-profile.json new file mode 100644 index 000000000..4061a7313 --- /dev/null +++ b/apps/maple-research/frontend/src-tauri/desktop-dev-profile.json @@ -0,0 +1,16 @@ +{ + "identifier": "cloud.opensecret.maple.dev", + "productName": "Maple Research Dev", + "scheme": "cloud.opensecret.maple.dev", + "environment": { + "MAPLE_DESKTOP_VARIANT": "dev", + "VITE_MAPLE_DESKTOP_VARIANT": "dev", + "VITE_MAPLE_APP_VARIANT": "dev", + "VITE_MAPLE_DEV_AUTH_ORIGIN": "https://auth-dev.maple.ai", + "VITE_OPEN_SECRET_API_URL": "https://enclave.secretgpt.ai", + "VITE_OPEN_SECRET_PCR_ENVIRONMENT": "development", + "VITE_CLIENT_ID": "ba5a14b5-d915-47b1-b7b1-afda52bc5fc6", + "VITE_OS_FLAGS_BASE_URL": "https://flags-dev.opensecret.cloud", + "VITE_MAPLE_BILLING_API_URL": "https://billing-dev.opensecret.cloud" + } +} diff --git a/apps/maple-research/frontend/src-tauri/src/desktop_app_profile.rs b/apps/maple-research/frontend/src-tauri/src/desktop_app_profile.rs new file mode 100644 index 000000000..0867306e4 --- /dev/null +++ b/apps/maple-research/frontend/src-tauri/src/desktop_app_profile.rs @@ -0,0 +1,81 @@ +const DEV_IDENTIFIER: &str = "cloud.opensecret.maple.dev"; +#[cfg(any(desktop, test))] +const PRODUCTION_SCHEME: &str = "cloud.opensecret.maple"; + +/// The installed bundle identity owns native policy; renderer build variables +/// cannot enable the production updater or register the other app's scheme. +pub fn is_development(identifier: &str) -> bool { + identifier == DEV_IDENTIFIER +} + +#[cfg(any(desktop, test))] +pub fn callback_scheme(identifier: &str) -> &'static str { + if is_development(identifier) { + DEV_IDENTIFIER + } else { + PRODUCTION_SCHEME + } +} + +pub fn accepts_callback_scheme(identifier: &str, scheme: Option<&str>) -> bool { + if is_development(identifier) { + scheme == Some(DEV_IDENTIFIER) + } else { + // Preserve production's existing HTTPS/payment routing and downstream + // validation while never consuming a return intended for Maple Dev. + scheme != Some(DEV_IDENTIFIER) + } +} + +#[cfg(any(desktop, test))] +pub fn updates_enabled(identifier: &str) -> bool { + !is_development(identifier) +} + +#[cfg(any(desktop, test))] +pub fn legacy_cleanup_enabled(identifier: &str) -> bool { + // The legacy cleanup resolves a historical production directory directly. + // A Dev launch must not remove files from that separate installation. + !is_development(identifier) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn each_profile_registers_only_its_own_scheme() { + assert_eq!(callback_scheme(PRODUCTION_SCHEME), PRODUCTION_SCHEME); + assert_eq!(callback_scheme(DEV_IDENTIFIER), DEV_IDENTIFIER); + } + + #[test] + fn dev_accepts_only_its_custom_scheme() { + assert!(accepts_callback_scheme( + DEV_IDENTIFIER, + Some(DEV_IDENTIFIER) + )); + for scheme in [Some(PRODUCTION_SCHEME), Some("https"), Some("http"), None] { + assert!(!accepts_callback_scheme(DEV_IDENTIFIER, scheme)); + } + } + + #[test] + fn production_rejects_dev_without_changing_payment_routing() { + assert!(!accepts_callback_scheme( + PRODUCTION_SCHEME, + Some(DEV_IDENTIFIER) + )); + for scheme in [Some(PRODUCTION_SCHEME), Some("https")] { + assert!(accepts_callback_scheme(PRODUCTION_SCHEME, scheme)); + } + } + + #[test] + fn dev_cannot_update_or_clean_production_state() { + assert!(!updates_enabled(DEV_IDENTIFIER)); + assert!(!legacy_cleanup_enabled(DEV_IDENTIFIER)); + assert!(updates_enabled(PRODUCTION_SCHEME)); + assert!(legacy_cleanup_enabled(PRODUCTION_SCHEME)); + } +} diff --git a/apps/maple-research/frontend/src-tauri/src/lib.rs b/apps/maple-research/frontend/src-tauri/src/lib.rs index 5eafbd301..be0463138 100644 --- a/apps/maple-research/frontend/src-tauri/src/lib.rs +++ b/apps/maple-research/frontend/src-tauri/src/lib.rs @@ -9,6 +9,7 @@ mod agent_acp; mod agent_host; #[cfg(desktop)] mod agent_tauri; +mod desktop_app_profile; #[cfg(test)] mod ios_app_variant; #[cfg(any(desktop, target_os = "ios"))] @@ -32,6 +33,7 @@ mod word_extractor; #[cfg(desktop)] #[tauri::command] async fn restart_for_update(app_handle: tauri::AppHandle) -> Result<(), String> { + ensure_profile_updates_enabled(&app_handle)?; // Never restart while a bundle replacement is in progress, and do not let // renderer code turn this updater-only command into a general restart. let _install_guard = UPDATE_INSTALL_LOCK.lock().await; @@ -113,6 +115,7 @@ async fn install_pending_update( app_handle: tauri::AppHandle, expected_version: String, ) -> Result { + ensure_profile_updates_enabled(&app_handle)?; // Only one native bundle mutation may run. A second visible Install action // coalesces behind the first and receives its resulting native state; it // must never launch a second package-manager/UAC prompt automatically. @@ -239,10 +242,11 @@ fn handle_desktop_run_event(app_handle: &tauri::AppHandle, event: tauri::RunEven // This handles incoming deep links fn handle_deep_link_event(url: &str, app: &tauri::AppHandle) { - // Maple Dev must never process a return intended for the installed production app. - if app.config().identifier == "cloud.opensecret.maple.dev" - && !tauri::Url::parse(url).is_ok_and(|url| url.scheme() == "cloud.opensecret.maple.dev") - { + let parsed = tauri::Url::parse(url).ok(); + if !desktop_app_profile::accepts_callback_scheme( + &app.config().identifier, + parsed.as_ref().map(tauri::Url::scheme), + ) { log::warn!("[Deep Link] Ignoring callback for another app"); return; } @@ -344,7 +348,9 @@ pub fn run() { #[cfg(target_os = "macos")] enable_main_window_frame_autosave(app.handle()); - legacy_tts_cleanup::schedule(app.handle()); + if desktop_app_profile::legacy_cleanup_enabled(&app.config().identifier) { + legacy_tts_cleanup::schedule(app.handle()); + } let service = agent_host::build_service(app.handle())?; if !app.manage(service) { @@ -363,7 +369,9 @@ pub fn run() { }); // Optionally register the scheme at runtime #[cfg(desktop)] - if let Err(e) = app.deep_link().register("cloud.opensecret.maple") { + let callback_scheme = + desktop_app_profile::callback_scheme(&app.config().identifier); + if let Err(e) = app.deep_link().register(callback_scheme) { log::error!("[Deep Link] Failed to register scheme: {e}"); } // Windows startup diagnostic: confirm the OS has our custom scheme @@ -372,10 +380,10 @@ pub fn run() { // HKCU\Software\Classes\cloud.opensecret.maple key (no installer run, // a deleted dev build, or a stale path), so log it once at startup. #[cfg(target_os = "windows")] - match app.deep_link().is_registered("cloud.opensecret.maple") { - Ok(true) => log::info!("[Deep Link] scheme 'cloud.opensecret.maple' is registered"), + match app.deep_link().is_registered(callback_scheme) { + Ok(true) => log::info!("[Deep Link] scheme '{callback_scheme}' is registered"), Ok(false) => log::warn!( - "[Deep Link] scheme 'cloud.opensecret.maple' is NOT registered; OAuth/payment deep links will not reach the app" + "[Deep Link] scheme '{callback_scheme}' is NOT registered; OAuth/payment deep links will not reach the app" ), Err(e) => log::error!("[Deep Link] is_registered check failed: {e}"), } @@ -387,6 +395,9 @@ pub fn run() { // Check on startup and hourly while automatic updates remain enabled. let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { + if !desktop_app_profile::updates_enabled(&app_handle.config().identifier) { + return; + } // Wait for app to fully initialize (use async sleep to not block the thread) tokio::time::sleep(std::time::Duration::from_secs(5)).await; @@ -440,6 +451,7 @@ pub fn run() { let check_updates_item = MenuItemBuilder::with_id(check_updates_id, "Check for Updates") + .enabled(desktop_app_profile::updates_enabled(&app.config().identifier)) .build(handle)?; // For macOS, we need to create a proper submenu structure @@ -688,6 +700,7 @@ enum UpdateCheckTrigger { #[derive(Debug, PartialEq, Eq, serde::Serialize)] #[serde(tag = "status", rename_all = "snake_case")] enum UpdateCheckResult { + UpdatesDisabledForProfile, AutomaticUpdatesDisabled, UpToDate, ReadyToRestart { @@ -915,6 +928,15 @@ async fn automatic_updates_enabled(app_handle: &tauri::AppHandle) -> Result Result<(), String> { + if desktop_app_profile::updates_enabled(&app_handle.config().identifier) { + Ok(()) + } else { + Err("Updates are disabled for this Maple Dev build.".to_string()) + } +} + #[cfg(desktop)] async fn automatic_update_may_continue( app_handle: &tauri::AppHandle, @@ -944,6 +966,12 @@ async fn check_for_updates( ) -> Result { use tauri_plugin_updater::UpdaterExt; + // Neither manual requests nor persisted preferences can opt a Dev bundle + // into production update discovery/downloads. + if !desktop_app_profile::updates_enabled(&app_handle.config().identifier) { + return Ok(UpdateCheckResult::UpdatesDisabledForProfile); + } + let _check_guard = UPDATE_CHECK_LOCK.lock().await; if !automatic_update_may_continue(&app_handle, trigger).await? { diff --git a/apps/maple-research/frontend/src-tauri/tauri.desktop-dev.conf.json b/apps/maple-research/frontend/src-tauri/tauri.desktop-dev.conf.json new file mode 100644 index 000000000..32800c8e0 --- /dev/null +++ b/apps/maple-research/frontend/src-tauri/tauri.desktop-dev.conf.json @@ -0,0 +1,136 @@ +{ + "$schema": "../node_modules/@tauri-apps/cli/config.schema.json", + "productName": "Maple Research Dev", + "identifier": "cloud.opensecret.maple.dev", + "build": { + "beforeBuildCommand": null + }, + "app": { + "windows": [ + { + "title": "Maple Research Dev", + "width": 1200, + "height": 800, + "minWidth": 800, + "minHeight": 600, + "center": true + } + ], + "security": { + "capabilities": [ + { + "identifier": "desktop-dev", + "description": "Research Dev desktop permissions, isolated from legacy production storage", + "windows": [ + "main" + ], + "platforms": [ + "macOS" + ], + "permissions": [ + "core:default", + "dialog:default", + "fs:default", + { + "identifier": "fs:allow-read-file", + "allow": [ + { + "path": "$APPCONFIG/**" + } + ] + }, + { + "identifier": "fs:allow-write-file", + "allow": [ + { + "path": "$APPCONFIG/**" + } + ] + }, + { + "identifier": "fs:allow-create", + "allow": [ + { + "path": "$APPCONFIG/**" + } + ] + }, + { + "identifier": "fs:allow-exists", + "allow": [ + { + "path": "$APPCONFIG/**" + } + ] + }, + { + "identifier": "fs:allow-mkdir", + "allow": [ + { + "path": "$APPCONFIG" + } + ] + }, + { + "identifier": "opener:allow-open-url", + "allow": [ + { + "url": "mailto:*" + }, + { + "url": "http://localhost:5173/*" + }, + { + "url": "https://trymaple.ai/*" + }, + { + "url": "https://*.stripe.com/*" + }, + { + "url": "https://*.stripe.network/*" + }, + { + "url": "https://*.zaprite.com/*" + }, + { + "url": "https://opensecret.cloud/*" + }, + { + "url": "https://*" + } + ] + }, + "opener:allow-reveal-item-in-dir", + "sign-in-with-apple:default", + "deep-link:allow-get-current" + ] + } + ] + } + }, + "plugins": { + "updater": { + "endpoints": [] + }, + "deep-link": { + "desktop": { + "schemes": [ + "cloud.opensecret.maple.dev" + ] + }, + "mobile": [] + } + }, + "bundle": { + "targets": [ + "app" + ], + "createUpdaterArtifacts": false, + "icon": [ + "icons/dev/icon.png" + ], + "macOS": { + "signingIdentity": null + } + } +} diff --git a/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx b/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx index 5ecb2b0ff..beef7ea6d 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.test.tsx @@ -109,6 +109,10 @@ mock.module("@tauri-apps/api/event", () => ({ } })); +let startupUrls: string[] | null = null; +const getCurrent = mock(async () => startupUrls); +mock.module("@tauri-apps/plugin-deep-link", () => ({ getCurrent })); + type PendingNativeOAuthAttempt = import("@/services/nativeOAuthAttempt").PendingNativeOAuthAttempt; const { beginNativeOAuthAttempt, readPendingNativeOAuthAttempt } = await import("@/services/nativeOAuthAttempt"); @@ -174,6 +178,8 @@ describe("DeepLinkHandler native auth callbacks", () => { beforeEach(async () => { deepLinkListener = undefined; + startupUrls = null; + getCurrent.mockClear(); renderer = null; storage = new MemoryStorage(); location = { href: "tauri://localhost/" }; @@ -401,6 +407,47 @@ describe("DeepLinkHandler native auth callbacks", () => { expect(sharedState().installCalls).toHaveLength(1); }); + test("Dev reads a buffered launch callback through the same attempt fence", async () => { + await prepareAttempt(); + startupUrls = [`cloud.opensecret.maple.dev://auth?handoff_grant=${VALID_GRANT}`]; + await act(async () => { + renderer?.update( + + + + ); + await Promise.resolve(); + }); + expect(getCurrent).toHaveBeenCalledTimes(1); + expect(renderer?.root.findByType(NativeOAuthAccountConfirmation).props.account.userId).toBe( + USER_ID + ); + await flushDeepLink(startupUrls[0]); + expect(nativeCalls).toHaveLength(1); + await decideAccount(true, Promise.resolve()); + expect(sharedState().installCalls).toHaveLength(1); + }); + + test("Dev cold launch ignores other-app and unsolicited callbacks; production does not read startup URLs", async () => { + expect(getCurrent).not.toHaveBeenCalled(); + startupUrls = [ + `cloud.opensecret.maple://auth?handoff_grant=${VALID_GRANT}`, + `cloud.opensecret.maple.dev://auth?handoff_grant=${VALID_GRANT}` + ]; + await act(async () => { + renderer?.update( + + + + ); + await Promise.resolve(); + }); + expect(getCurrent).toHaveBeenCalledTimes(1); + expect(nativeCalls).toHaveLength(0); + expect(sharedState().installCalls).toHaveLength(0); + expect(location.href).toBe("tauri://localhost/"); + }); + test("ignores callbacks without a pending attempt or while already authenticated", async () => { await flushDeepLink(`cloud.opensecret.maple://auth?handoff_grant=${VALID_GRANT}`); diff --git a/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx b/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx index 1c0bcb700..7e5628c04 100644 --- a/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx +++ b/apps/maple-research/frontend/src/components/DeepLinkHandler.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useOpenSecret } from "@mapleai/sdk"; import { isTauri } from "@/utils/platform"; import { listen } from "@tauri-apps/api/event"; +import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { getSafeInternalRedirect } from "@/utils/internalRedirect"; import { authorizeNativeOAuthCallback, @@ -23,8 +24,9 @@ import { export function DeepLinkHandler({ tauri = isTauri(), - appVariant = mapleAppVariant() -}: { tauri?: boolean; appVariant?: MapleAppVariant } = {}) { + appVariant = mapleAppVariant(), + desktopDev = import.meta.env.VITE_MAPLE_DESKTOP_VARIANT === "dev" +}: { tauri?: boolean; appVariant?: MapleAppVariant; desktopDev?: boolean } = {}) { const os = useOpenSecret(); const { showNotification } = useNotification(); const isAuthenticatedRef = useRef(false); @@ -71,10 +73,8 @@ export function DeepLinkHandler({ if (tauri) { console.log("[Deep Link] Setting up handler for Tauri app"); - // Listen for the custom event we emit from Rust - unlisten = await listen("deep-link-received", async (event) => { + const handleUrl = async (url: string) => { if (disposed) return; - const url = event.payload; let isAuthenticationCallback = false; console.log("[Deep Link] Received callback"); @@ -212,13 +212,23 @@ export function DeepLinkHandler({ console.error("[Deep Link] Failed to process deep link", error); } } - }); + }; + // Subscribe before reading the launch URL so callbacks during startup + // use the same account/attempt fence as already-running returns. + unlisten = await listen("deep-link-received", (event) => + handleUrl(event.payload) + ); if (disposed) { unlisten(); return; } + if (desktopDev && appVariant === "dev") { + for (const url of (await getCurrent()) ?? []) { + await handleUrl(url); + } + } console.log("[Deep Link] Handler setup complete"); } } catch (error) { @@ -237,7 +247,7 @@ export function DeepLinkHandler({ resolveConfirmation(false); if (unlisten) unlisten(); }; - }, [appVariant, confirmAccount, resolveConfirmation, showNotification, tauri]); + }, [appVariant, confirmAccount, desktopDev, resolveConfirmation, showNotification, tauri]); return confirmation ? ( diff --git a/apps/maple-research/frontend/src/components/settings/UpdateSettings.test.tsx b/apps/maple-research/frontend/src/components/settings/UpdateSettings.test.tsx index 9aaf1a9c9..538528496 100644 --- a/apps/maple-research/frontend/src/components/settings/UpdateSettings.test.tsx +++ b/apps/maple-research/frontend/src/components/settings/UpdateSettings.test.tsx @@ -145,6 +145,26 @@ describe("UpdateSettings", () => { consoleError.mockRestore(); }); + test("explains that a Dev package cannot use the production updater", async () => { + const updates = service({ + checkForUpdates: mock(async () => ({ status: "updates_disabled_for_profile" as const })) + }); + await act(async () => { + renderer = create(); + await Promise.resolve(); + }); + const checkButton = renderer!.root + .findAllByType("button") + .find((button) => textContent(button).includes("Check for updates")); + await act(async () => { + checkButton!.props.onClick(); + await Promise.resolve(); + }); + expect(textContent(renderer!.root)).toContain( + "Updates are disabled for this Maple Dev build. Install a new Dev build to update." + ); + }); + test("turns a user-requested ready result into a persistent restart action", async () => { const updates = service({ checkForUpdates: mock(async () => ({ diff --git a/apps/maple-research/frontend/src/components/settings/UpdateSettings.tsx b/apps/maple-research/frontend/src/components/settings/UpdateSettings.tsx index 91d8e765c..cb265f361 100644 --- a/apps/maple-research/frontend/src/components/settings/UpdateSettings.tsx +++ b/apps/maple-research/frontend/src/components/settings/UpdateSettings.tsx @@ -21,6 +21,8 @@ function updateCheckMessage(result: UpdateCheckResult): string { return `Version ${result.version} is installed. Restart Maple to finish updating.`; case "ready_to_install": return `Version ${result.version} is downloaded and ready to install.`; + case "updates_disabled_for_profile": + return "Updates are disabled for this Maple Dev build. Install a new Dev build to update."; case "automatic_updates_disabled": return "Automatic updates are off. You can still check for updates manually."; } diff --git a/apps/maple-research/frontend/src/services/desktopOAuthTransport.test.ts b/apps/maple-research/frontend/src/services/desktopOAuthTransport.test.ts index a2bf8261c..8152fc19a 100644 --- a/apps/maple-research/frontend/src/services/desktopOAuthTransport.test.ts +++ b/apps/maple-research/frontend/src/services/desktopOAuthTransport.test.ts @@ -95,6 +95,7 @@ describe("desktop OAuth transport selection", () => { expect(parsed.searchParams.get("native_request_id")).toBe(nativeRequestId); expect(parsed.searchParams.has("native_oauth_attempt")).toBe(false); expect(parsed.searchParams.has("next")).toBe(false); + expect([...parsed.searchParams.keys()]).toHaveLength(4); expect(parsed.hash).toBe(""); }); @@ -108,12 +109,11 @@ describe("desktop OAuth transport selection", () => { }); test("dev launch selects the dev site and keeps the callback marker allowlisted", () => { - const url = new URL( - buildTransportV2DesktopAuthUrl(state, "dev", "https://dev-auth.example.test") - ); - expect(url.origin).toBe("https://dev-auth.example.test"); + const url = new URL(buildTransportV2DesktopAuthUrl(state, "dev", "https://auth-dev.maple.ai")); + expect(url.origin).toBe("https://auth-dev.maple.ai"); expect(url.pathname).toBe("/desktop-auth"); expect(url.searchParams.get("native_app_variant")).toBe("dev"); + expect([...url.searchParams.keys()]).toHaveLength(5); expect(url.searchParams.get("native_session_id")).toBe(nativeSessionId); expect(url.searchParams.get("native_request_id")).toBe(nativeRequestId); }); diff --git a/apps/maple-research/frontend/src/services/updateService.ts b/apps/maple-research/frontend/src/services/updateService.ts index fd9d6f064..53b707db2 100644 --- a/apps/maple-research/frontend/src/services/updateService.ts +++ b/apps/maple-research/frontend/src/services/updateService.ts @@ -7,6 +7,7 @@ export interface UpdaterPreferences { export type UpdateCheckResult = | { status: "automatic_updates_disabled" } + | { status: "updates_disabled_for_profile" } | { status: "up_to_date" } | { status: "ready_to_restart"; version: string } | { diff --git a/apps/maple-research/frontend/src/vite-env.d.ts b/apps/maple-research/frontend/src/vite-env.d.ts index 32f7043c4..69ba163f0 100644 --- a/apps/maple-research/frontend/src/vite-env.d.ts +++ b/apps/maple-research/frontend/src/vite-env.d.ts @@ -1,6 +1,7 @@ /// interface ImportMetaEnv { + readonly VITE_MAPLE_DESKTOP_VARIANT?: "dev"; readonly VITE_MAPLE_APP_VARIANT?: "production" | "dev"; readonly VITE_MAPLE_DEV_AUTH_ORIGIN?: string; readonly VITE_STOREKIT_EXPERIMENT?: string; diff --git a/flake.nix b/flake.nix index e317022af..177ad4258 100644 --- a/flake.nix +++ b/flake.nix @@ -716,6 +716,7 @@ } '' cd "$src" python3 scripts/ci/test_select_xcode.py + python3 scripts/ci/test_research_dev_profile.py touch "$out" ''; diff --git a/scripts/ci/change_detection.py b/scripts/ci/change_detection.py index 0d2d5a0af..63252d66c 100644 --- a/scripts/ci/change_detection.py +++ b/scripts/ci/change_detection.py @@ -102,6 +102,8 @@ "scripts/ci/ios-pr.sh": frozenset({"ios"}), "scripts/ci/ios-release.sh": frozenset({"ios"}), "scripts/ci/ios-variant.sh": frozenset({"ios"}), + "scripts/ci/research-dev-desktop.sh": frozenset({"macos"}), + "scripts/ci/research-dev-profile.py": frozenset({"macos"}), "scripts/ci/select-xcode.py": frozenset({"macos", "ios", "ios_onnx"}), "scripts/ci/verify-release-artifacts.sh": NATIVE_PLATFORMS, "scripts/ci/web.sh": frozenset({"frontend"}), diff --git a/scripts/ci/research-dev-desktop.sh b/scripts/ci/research-dev-desktop.sh new file mode 100755 index 000000000..0c8a202e1 --- /dev/null +++ b/scripts/ci/research-dev-desktop.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Manual, unsigned macOS host-architecture package. No upload or publication. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_common.sh" +if [ "$(host_os)" != "darwin" ]; then + echo "Research Dev desktop packaging currently supports macOS." >&2 + exit 1 +fi +print_source_provenance +verify_rust_lockfile +install_frontend_deps +configure_sccache +use_pr_environment +# This profile is independent of the existing iOS Dev repository variable. +profile_exports="$(python3 "${SCRIPT_DIR}/research-dev-profile.py" environment)" +eval "${profile_exports}" +unset CARGO_TARGET_DIR MAPLE_IOS_VARIANT TAURI_CONFIG APPLE_SIGNING_IDENTITY TAURI_SIGNING_PRIVATE_KEY TAURI_SIGNING_PRIVATE_KEY_PASSWORD +configure_reproducible_build_metadata +remove_generated_ios_cargo_config +build_frontend_dist +use_xcode_toolchain +prepare_macos_onnxruntime +export MACOSX_DEPLOYMENT_TARGET="13.4" +export CMAKE_OSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET}" +cd "${FRONTEND_DIR}" +bun tauri build --debug --bundles app --no-sign --config src-tauri/tauri.desktop-dev.conf.json +verify_frontend_dist_unchanged +app_dir="${TAURI_DIR}/target/debug/bundle/macos/Maple Research Dev.app" +evidence_dir="${TAURI_DIR}/target/research-dev" +mkdir -p "${evidence_dir}" +python3 "${SCRIPT_DIR}/research-dev-profile.py" verify --bundle "${app_dir}" > "${evidence_dir}/build-profile.json" +COPYFILE_DISABLE=1 tar -czf "${evidence_dir}/maple-research-dev-macos.tar.gz" -C "$(dirname "${app_dir}")" "$(basename "${app_dir}")" +printf 'Verified Research Dev package: %s\nProfile evidence: %s\n' "${app_dir}" "${evidence_dir}/build-profile.json" diff --git a/scripts/ci/research-dev-profile.py b/scripts/ci/research-dev-profile.py new file mode 100644 index 000000000..f14bcdf09 --- /dev/null +++ b/scripts/ci/research-dev-profile.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Fixed local Research Dev packaging inputs and macOS artifact checks.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import plistlib +import shlex + +ROOT = Path(__file__).resolve().parents[2] +TAURI = ROOT / "apps/maple-research/frontend/src-tauri" +PROFILE = TAURI / "desktop-dev-profile.json" + + +def validate_config(profile, overlay): + if overlay["identifier"] != profile["identifier"] or overlay["productName"] != profile["productName"]: + raise ValueError("Dev package identity does not match its profile") + if overlay["plugins"]["deep-link"]["desktop"]["schemes"] != [profile["scheme"]]: + raise ValueError("Dev package must own only its Dev scheme") + if overlay["plugins"]["updater"]["endpoints"] != [] or overlay["bundle"]["createUpdaterArtifacts"] is not False: + raise ValueError("Dev package must not consume or produce updater artifacts") + permissions = overlay["app"]["security"]["capabilities"] + if any(not isinstance(cap, dict) for cap in permissions) or "$HOME" in json.dumps(permissions): + raise ValueError("Dev capabilities must not inherit legacy production storage access") + + +def verify_bundle(bundle, profile): + with (bundle / "Contents/Info.plist").open("rb") as stream: + info = plistlib.load(stream) + if info["CFBundleIdentifier"] != profile["identifier"]: + raise ValueError("Packaged bundle has the wrong identity") + if info["CFBundleName"] != profile["productName"]: + raise ValueError("Packaged bundle has the wrong name") + schemes = [scheme for entry in info.get("CFBundleURLTypes", []) + for scheme in entry.get("CFBundleURLSchemes", [])] + if schemes != [profile["scheme"]]: + raise ValueError("Packaged bundle has the wrong callback schemes") + binary = bundle / "Contents/MacOS" / info["CFBundleExecutable"] + return {"bundle_identifier": info["CFBundleIdentifier"], "scheme": schemes[0], + "executable_sha256": hashlib.sha256(binary.read_bytes()).hexdigest()} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("command", choices=["environment", "verify"]) + parser.add_argument("--bundle", type=Path) + args = parser.parse_args() + profile = json.loads(PROFILE.read_text()) + validate_config(profile, json.loads((TAURI / "tauri.desktop-dev.conf.json").read_text())) + if args.command == "environment": + for key, value in profile["environment"].items(): + print(f"export {key}={shlex.quote(value)}") + else: + if args.bundle is None: + parser.error("verify requires --bundle") + if any(os.environ.get(key) != value for key, value in profile["environment"].items()): + raise ValueError("Build environment drifted from the fixed Dev profile") + evidence = verify_bundle(args.bundle, profile) + print(json.dumps({"profile": profile, "artifact": evidence, + "frontend_sha256": os.environ["MAPLE_FRONTEND_DIST_TREE_SHA256"]}, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/test_agent_workflows.py b/scripts/ci/test_agent_workflows.py index 6de17b6bd..b1949a443 100644 --- a/scripts/ci/test_agent_workflows.py +++ b/scripts/ci/test_agent_workflows.py @@ -39,7 +39,7 @@ def test_contributor_build_has_no_credentials_or_privileged_events(self): config = workflow("agent-ci.yml") self.assertEqual(set(config["on"]), {"push", "pull_request", "workflow_dispatch"}) self.assertEqual(config["on"]["push"]["branches"], ["master"]) - self.assertEqual(config["on"]["pull_request"]["branches"], ["master"]) + self.assertIsNone(config["on"]["pull_request"]) self.assertEqual(config["permissions"], {"contents": "read"}) for value in strings(config): self.assertNotRegex(value, r"\bsecrets\b|github\.token|\bGH_TOKEN\b") @@ -146,7 +146,7 @@ def test_only_trusted_master_can_receive_apple_signing_credentials(self): self.assertEqual(config["name"], "Maple Agent Desktop Builds") self.assertEqual(config["on"], { "push": {"branches": ["master"]}, - "pull_request": {"branches": ["master"]}, + "pull_request": None, "workflow_dispatch": None, }) self.assertEqual(config["permissions"], {"contents": "read"}) diff --git a/scripts/ci/test_change_detection.py b/scripts/ci/test_change_detection.py index f6421b163..d79622c6a 100644 --- a/scripts/ci/test_change_detection.py +++ b/scripts/ci/test_change_detection.py @@ -168,6 +168,14 @@ def test_apple_toolchain_inputs_select_apple_builds_and_onnx_cache_warmer(self) self.assert_routes([path], "macos", "ios", "ios_onnx") self.assertIn(path, IOS_ONNX_INPUTS) + def test_research_dev_packaging_scripts_select_macos(self) -> None: + for path in ( + "scripts/ci/research-dev-desktop.sh", + "scripts/ci/research-dev-profile.py", + ): + with self.subTest(path=path): + self.assert_routes([path], "macos") + def test_mixed_changes_union_their_routes(self) -> None: self.assert_routes( ["sdk/src/lib/index.ts", "apps/maple-research/frontend/src/routes/index.tsx", "apps/maple-research/frontend/src-tauri/gen/android/build.gradle.kts"], diff --git a/scripts/ci/test_research_dev_profile.py b/scripts/ci/test_research_dev_profile.py new file mode 100644 index 000000000..2f1f685e3 --- /dev/null +++ b/scripts/ci/test_research_dev_profile.py @@ -0,0 +1,71 @@ +import copy +import importlib.util +import json +from pathlib import Path +import plistlib +import subprocess +import tempfile +import unittest + +SPEC = importlib.util.spec_from_file_location("research_dev_profile", Path(__file__).with_name("research-dev-profile.py")) +profile_module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(profile_module) + + +class ResearchDevProfileTests(unittest.TestCase): + def setUp(self): + self.profile = json.loads(profile_module.PROFILE.read_text()) + self.overlay = json.loads((profile_module.TAURI / "tauri.desktop-dev.conf.json").read_text()) + + def test_no_production_identity_updater_or_storage(self): + profile_module.validate_config(self.profile, self.overlay) + mutations = [ + lambda o: o.update(identifier="cloud.opensecret.maple"), + lambda o: o["plugins"]["deep-link"]["desktop"].update(schemes=["cloud.opensecret.maple"]), + lambda o: o["plugins"]["updater"].update(endpoints=["https://updates.trymaple.ai/latest.json"]), + lambda o: o["bundle"].update(createUpdaterArtifacts=True), + lambda o: o["app"]["security"].update(capabilities=["default"]), + ] + for mutate in mutations: + with self.subTest(mutation=mutate): + invalid = copy.deepcopy(self.overlay) + mutate(invalid) + with self.assertRaises(ValueError): + profile_module.validate_config(self.profile, invalid) + + def test_checks_actual_package_identity_and_scheme(self): + with tempfile.TemporaryDirectory() as directory: + bundle = Path(directory) + (bundle / "Contents/MacOS").mkdir(parents=True) + (bundle / "Contents/MacOS/maple").write_bytes(b"fixture executable") + info = {"CFBundleIdentifier": self.profile["identifier"], "CFBundleName": self.profile["productName"], + "CFBundleExecutable": "maple", "CFBundleURLTypes": [{"CFBundleURLSchemes": [self.profile["scheme"]]}]} + path = bundle / "Contents/Info.plist" + path.write_bytes(plistlib.dumps(info)) + self.assertEqual(profile_module.verify_bundle(bundle, self.profile)["scheme"], "cloud.opensecret.maple.dev") + info["CFBundleURLTypes"][0]["CFBundleURLSchemes"] = ["cloud.opensecret.maple"] + path.write_bytes(plistlib.dumps(info)) + with self.assertRaises(ValueError): + profile_module.verify_bundle(bundle, self.profile) + + def test_pr_artifact_job_has_no_publication_or_signing_authority(self): + workflow = json.loads(subprocess.check_output([ + "yq", "-o=json", ".", str(profile_module.ROOT / ".github/workflows/desktop-pr-build.yml") + ], text=True)) + self.assertEqual(set(workflow["on"]), {"pull_request"}) + self.assertEqual(workflow["permissions"], {"contents": "read"}) + job = workflow["jobs"]["build-research-dev-macos"] + self.assertEqual(job["needs"], "changes") + self.assertIn("needs.changes.outputs.macos", job["if"]) + self.assertNotIn("environment", job) + self.assertNotIn("secrets.", json.dumps(job)) + self.assertTrue(any("./scripts/ci/research-dev-desktop.sh" in step.get("run", "") for step in job["steps"])) + artifact = next(step["with"] for step in job["steps"] if step.get("uses", "").startswith("actions/upload-artifact@")) + self.assertEqual(artifact["name"], "maple-research-dev-macos-pr") + self.assertEqual(artifact["if-no-files-found"], "error") + self.assertIn("build-profile.json", artifact["path"]) + self.assertIn("maple-research-dev-macos.tar.gz", artifact["path"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/test_select_xcode.py b/scripts/ci/test_select_xcode.py index 94d5e504f..17e0f4fc3 100644 --- a/scripts/ci/test_select_xcode.py +++ b/scripts/ci/test_select_xcode.py @@ -160,6 +160,7 @@ def test_no_workflow_uses_retired_mac_os_and_every_native_selector_has_checkout( ("agent-desktop-build.yml", "verify-macos"), ("desktop-build.yml", "build-macos"), ("desktop-pr-build.yml", "build-macos"), + ("desktop-pr-build.yml", "build-research-dev-macos"), ("ios-dev-testflight.yml", "build-ios-dev"), ("ios-dev-testflight.yml", "submit-ios-dev-testflight"), ("mobile-build.yml", "build-ios"), From 24730bfac6399d1685899b0d31a604b5f5979db2 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 8 Oct 2026 17:13:14 +0000 Subject: [PATCH 2/4] Flush GPUI disposal before asserting login task cancellation --- apps/maple-agent/app/src/ui/login.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/maple-agent/app/src/ui/login.rs b/apps/maple-agent/app/src/ui/login.rs index 08fec170a..311d51a0b 100644 --- a/apps/maple-agent/app/src/ui/login.rs +++ b/apps/maple-agent/app/src/ui/login.rs @@ -684,7 +684,9 @@ mod tests { ); }); cx.run_until_parked(); - drop(screen); + // GPUI queues entity disposal until an App update flushes its effects. + // Run that cycle so LoginScreen::Drop aborts the pending backend task. + cx.update(|_| drop(screen)); cx.run_until_parked(); assert!( weak.upgrade().is_none(), From df0f0dba7c476142bcd442bda5eaa01539669d10 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Thu, 8 Oct 2026 23:01:23 +0000 Subject: [PATCH 3/4] Fix Research Dev macOS SDK library resolution Match the existing Research linker setup and reject build-host paths in the packaged executable. The Dev smoke artifact otherwise failed before startup on a Nix libiconv dependency. Validation: pinned apple-toolchain check passed (10 Xcode and 4 profile tests); shell syntax and diff checks passed. Full flake check and hook environment realization were blocked by local disk exhaustion; hosted CI must complete. --- .../docs/research-dev-desktop.md | 4 ++- scripts/ci/research-dev-desktop.sh | 5 ++++ scripts/ci/research-dev-profile.py | 25 +++++++++++++++++++ scripts/ci/test_research_dev_profile.py | 17 +++++++++++++ 4 files changed, 50 insertions(+), 1 deletion(-) diff --git a/apps/maple-research/docs/research-dev-desktop.md b/apps/maple-research/docs/research-dev-desktop.md index 3fcabe78b..76acefb64 100644 --- a/apps/maple-research/docs/research-dev-desktop.md +++ b/apps/maple-research/docs/research-dev-desktop.md @@ -35,7 +35,9 @@ nix develop --no-update-lock-file .#ci -c ./scripts/ci/research-dev-desktop.sh This builds a debug, host-architecture `.app`, without signing, notarization, upload, a new release lane, or updater artifacts. It verifies the actual bundle's identifier, display name, and registered scheme and records the executable hash -and frontend tree hash: +and frontend tree hash. It uses Research's existing macOS SDK linker setup and +rejects executable dependencies or runtime search paths pointing into the build +host's filesystem, including the Nix store: ```text apps/maple-research/frontend/src-tauri/target/debug/bundle/macos/Maple Research Dev.app diff --git a/scripts/ci/research-dev-desktop.sh b/scripts/ci/research-dev-desktop.sh index 0c8a202e1..2dcf0e839 100755 --- a/scripts/ci/research-dev-desktop.sh +++ b/scripts/ci/research-dev-desktop.sh @@ -22,6 +22,11 @@ use_xcode_toolchain prepare_macos_onnxruntime export MACOSX_DEPLOYMENT_TARGET="13.4" export CMAKE_OSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET}" +# Match Research's macOS PR/release linker setup: prefer Apple's SDK libraries +# over the Nix libiconv directory added by use_xcode_toolchain. +export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" +export LIBRARY_PATH="${SDKROOT}/usr/lib${LIBRARY_PATH:+:${LIBRARY_PATH}}" +export RUSTFLAGS="${RUSTFLAGS:+${RUSTFLAGS} }-Clink-arg=-isysroot -Clink-arg=${SDKROOT}" cd "${FRONTEND_DIR}" bun tauri build --debug --bundles app --no-sign --config src-tauri/tauri.desktop-dev.conf.json verify_frontend_dist_unchanged diff --git a/scripts/ci/research-dev-profile.py b/scripts/ci/research-dev-profile.py index f14bcdf09..ba889e8d9 100644 --- a/scripts/ci/research-dev-profile.py +++ b/scripts/ci/research-dev-profile.py @@ -6,7 +6,9 @@ import os from pathlib import Path import plistlib +import re import shlex +import subprocess ROOT = Path(__file__).resolve().parents[2] TAURI = ROOT / "apps/maple-research/frontend/src-tauri" @@ -41,6 +43,28 @@ def verify_bundle(bundle, profile): "executable_sha256": hashlib.sha256(binary.read_bytes()).hexdigest()} +def verify_macos_runtime_paths(binary): + loads = subprocess.check_output(["/usr/bin/otool", "-L", str(binary)], text=True) + paths = re.findall(r"^\s+(.+?) \(compatibility version ", loads, re.MULTILINE) + if not paths: + raise ValueError("Packaged executable has no inspectable Mach-O dependencies") + commands = subprocess.check_output(["/usr/bin/otool", "-l", str(binary)], text=True) + is_rpath = False + for line in commands.splitlines(): + if line.strip().startswith("cmd "): + is_rpath = line.strip() == "cmd LC_RPATH" + elif is_rpath: + match = re.match(r"\s*path (.+?) \(offset \d+\)", line) + if match: + paths.append(match[1]) + is_rpath = False + for path in paths: + if path.startswith(("@rpath/", "@loader_path/", "@executable_path/")): + continue + if not os.path.normpath(path).startswith(("/usr/lib/", "/System/Library/")): + raise ValueError(f"Packaged executable depends on a build-host runtime path: {path}") + + def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("command", choices=["environment", "verify"]) @@ -57,6 +81,7 @@ def main(): if any(os.environ.get(key) != value for key, value in profile["environment"].items()): raise ValueError("Build environment drifted from the fixed Dev profile") evidence = verify_bundle(args.bundle, profile) + verify_macos_runtime_paths(args.bundle / "Contents/MacOS/maple") print(json.dumps({"profile": profile, "artifact": evidence, "frontend_sha256": os.environ["MAPLE_FRONTEND_DIST_TREE_SHA256"]}, indent=2)) diff --git a/scripts/ci/test_research_dev_profile.py b/scripts/ci/test_research_dev_profile.py index 2f1f685e3..b85474070 100644 --- a/scripts/ci/test_research_dev_profile.py +++ b/scripts/ci/test_research_dev_profile.py @@ -6,6 +6,7 @@ import subprocess import tempfile import unittest +from unittest.mock import patch SPEC = importlib.util.spec_from_file_location("research_dev_profile", Path(__file__).with_name("research-dev-profile.py")) profile_module = importlib.util.module_from_spec(SPEC) @@ -66,6 +67,22 @@ def test_pr_artifact_job_has_no_publication_or_signing_authority(self): self.assertIn("build-profile.json", artifact["path"]) self.assertIn("maple-research-dev-macos.tar.gz", artifact["path"]) + def test_runtime_audit_rejects_host_dependencies_and_search_paths(self): + system_loads = "maple:\n\t/usr/lib/libiconv.2.dylib (compatibility version 7.0.0, current version 7.0.0)\n" + bundle_rpath = "Load command 0\n cmd LC_RPATH\n path @executable_path/../Frameworks (offset 12)\n" + with patch.object(profile_module.subprocess, "check_output", side_effect=[system_loads, bundle_rpath]): + profile_module.verify_macos_runtime_paths(Path("fixture/maple")) + for host_path in ("/nix/store/fixture/lib/libiconv.2.dylib", "/Volumes/runner/lib/libiconv.2.dylib", "/Users/runner/lib/libiconv.2.dylib"): + with self.subTest(host_path=host_path): + loads = system_loads.replace("/usr/lib/libiconv.2.dylib", host_path) + with patch.object(profile_module.subprocess, "check_output", side_effect=[loads, bundle_rpath]): + with self.assertRaisesRegex(ValueError, "build-host runtime path"): + profile_module.verify_macos_runtime_paths(Path("fixture/maple")) + commands = bundle_rpath.replace("@executable_path/../Frameworks", str(Path(host_path).parent)) + with patch.object(profile_module.subprocess, "check_output", side_effect=[system_loads, commands]): + with self.assertRaisesRegex(ValueError, "build-host runtime path"): + profile_module.verify_macos_runtime_paths(Path("fixture/maple")) + if __name__ == "__main__": unittest.main() From 79d62d0ae14b43b79dd079988aa0098cac24fb58 Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Fri, 9 Oct 2026 05:30:20 +0000 Subject: [PATCH 4/4] ci: use standard macOS runner for Research Dev rehearsal --- .github/workflows/desktop-pr-build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/desktop-pr-build.yml b/.github/workflows/desktop-pr-build.yml index 66d6f52bb..129f42b5b 100644 --- a/.github/workflows/desktop-pr-build.yml +++ b/.github/workflows/desktop-pr-build.yml @@ -58,7 +58,7 @@ jobs: build-research-dev-macos: needs: changes if: ${{ always() && !cancelled() && (needs.changes.result != 'success' || needs.changes.outputs.macos != 'false') }} - runs-on: macos-26-xlarge + runs-on: macos-26 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # was v4 with: