diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index e89c5f3f7..7bec3f636 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -374,7 +374,11 @@ const overrides = new Map([ // +5 (1068 -> 1073): merge with main, which independently added the // managed_agent_profile_reconcile_enabled flag (field + doc + init) under // its own 1042-line override. Union of two separately approved additions. - ["src-tauri/src/app_state.rs", 1054], + ["src-tauri/src/app_state.rs", 1090], + // lazy-workspace-activation adds seven command/state wiring lines to lib.rs. + // Keeping the registration and safety-gate setup together is clearer than a + // one-off extraction whose only purpose would be satisfying the line cap. + ["src-tauri/src/lib.rs", 1007], // multi-slot splitting + no-op suppression (#1309): the ReadStateManager // class grew from ~700 lines to ~1019 with the addition of // splitContextsIntoBudgetedSlots (pure fn + 5 tests), publishSplitSlots, diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 70ff474d7..00a6c59e6 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -18,6 +18,15 @@ use crate::managed_agents::ManagedAgentProcess; pub struct AppState { pub keys: Mutex, pub http_client: reqwest::Client, + /// A no-redirect client for authenticated relay media fetches (download, + /// clipboard copy, snapshot, editor). Every caller pre-validates the URL + /// origin, but the app-wide `http_client` follows redirects by default, so + /// a relay `/media/` URL returning a 3xx to an off-origin or private host + /// would forward the minted media Authorization header across origins — + /// a redirect-hop SSRF. This client treats any 3xx as a non-success + /// response (surfaced as an error) so the auth token never leaves the + /// validated relay origin. + pub media_fetch_client: reqwest::Client, /// Workspace-provided relay URL override. Set by `apply_workspace` on app /// init and takes priority over env vars and compile-time defaults. pub relay_url_override: Mutex>, @@ -155,6 +164,27 @@ fn identity_from_env() -> Option { } } +/// Build the no-redirect HTTP client used for authenticated relay media +/// fetches (download / copy). +/// +/// This client is a security boundary, not a convenience: it carries a minted +/// media `Authorization` header, so it MUST NOT follow redirects. A relay 3xx +/// to an off-origin or private host would otherwise forward that header across +/// origins (a redirect-hop SSRF). `redirect::Policy::none()` returns the 3xx +/// verbatim so the caller can reject it. +/// +/// Returned as a `Result` so the fail-closed invariant is testable — callers +/// must never substitute a redirect-following client on build failure. Shares +/// the localhost `resolve`/pool config with the app-wide `http_client`. +pub fn build_media_fetch_client() -> reqwest::Result { + reqwest::Client::builder() + .resolve("localhost", std::net::SocketAddr::from(([127, 0, 0, 1], 0))) + .pool_idle_timeout(std::time::Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .redirect(reqwest::redirect::Policy::none()) + .build() +} + pub fn build_app_state() -> AppState { // Env var takes precedence (dev/CI). If absent, resolve_persisted_identity() // in setup() will replace the ephemeral placeholder with a persisted key. @@ -177,6 +207,11 @@ pub fn build_app_state() -> AppState { .pool_max_idle_per_host(1) .build() .unwrap_or_else(|_| reqwest::Client::new()), + media_fetch_client: build_media_fetch_client().expect( + "media_fetch_client must build with redirect::Policy::none(); a \ + redirect-following fallback would forward the minted media auth \ + header across origins (redirect-hop SSRF)", + ), relay_url_override: Mutex::new(None), managed_agent_restore_pending: AtomicBool::new(false), managed_agent_profile_reconcile_enabled: AtomicBool::new(true), diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs index 9f05036ed..4af457e0f 100644 --- a/desktop/src-tauri/src/commands/workspace.rs +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -105,6 +105,7 @@ pub async fn apply_workspace( relay_url: String, nsec: Option, repos_dir: Option, + agent_managed_profiles: Option, app: AppHandle, ) -> Result<(), String> { let restore_app = app.clone(); @@ -151,6 +152,13 @@ pub async fn apply_workspace( *keys_guard = keys; } + // Keep the backend-side reconcile guard aligned with the frontend + // experiment before workspace activation can spawn any agents. Missing + // means the stable behavior: desktop remains authoritative. + state + .managed_agent_profile_reconcile_enabled + .store(!agent_managed_profiles.unwrap_or(false), Ordering::Release); + // ── One-shot legacy migration (non-fatal) ───────────────────────────── // Pin any blank-relay agent record to the first workspace applied // after boot — exactly what blank would have resolved to at boot