From 58dab88a423621277c1d7de9edb13d21873f40aa Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:07:19 -0400 Subject: [PATCH 01/31] Add Desktop workspace single sign-on --- .agents/skills/authentication/SKILL.md | 18 + .changeset/calm-desktops-federate.md | 5 + packages/core/docs/content/authentication.mdx | 23 ++ packages/core/docs/content/cross-app-sso.mdx | 22 ++ .../content/locales/ar-SA/authentication.mdx | 4 + .../content/locales/ar-SA/cross-app-sso.mdx | 4 + .../content/locales/de-DE/authentication.mdx | 4 + .../content/locales/de-DE/cross-app-sso.mdx | 4 + .../content/locales/es-ES/authentication.mdx | 4 + .../content/locales/es-ES/cross-app-sso.mdx | 4 + .../content/locales/fr-FR/authentication.mdx | 4 + .../content/locales/fr-FR/cross-app-sso.mdx | 4 + .../content/locales/hi-IN/authentication.mdx | 4 + .../content/locales/hi-IN/cross-app-sso.mdx | 4 + .../content/locales/ja-JP/authentication.mdx | 4 + .../content/locales/ja-JP/cross-app-sso.mdx | 4 + .../content/locales/ko-KR/authentication.mdx | 4 + .../content/locales/ko-KR/cross-app-sso.mdx | 4 + .../content/locales/pt-BR/authentication.mdx | 4 + .../content/locales/pt-BR/cross-app-sso.mdx | 4 + .../content/locales/zh-CN/authentication.mdx | 4 + .../content/locales/zh-CN/cross-app-sso.mdx | 4 + .../content/locales/zh-TW/authentication.mdx | 4 + .../content/locales/zh-TW/cross-app-sso.mdx | 4 + .../core/src/server/core-routes-plugin.ts | 2 + packages/core/src/server/identity-sso.spec.ts | 41 ++- packages/core/src/server/identity-sso.ts | 52 +++ packages/core/src/server/index.ts | 1 + packages/desktop-app/shared/ipc-channels.ts | 13 + .../src/main/desktop-identity.spec.ts | 233 ++++++++++++ .../desktop-app/src/main/desktop-identity.ts | 344 ++++++++++++++++++ packages/desktop-app/src/main/index.ts | 162 ++++++++- .../desktop-app/src/preload/index.spec.ts | 37 ++ packages/desktop-app/src/preload/index.ts | 20 + .../src/renderer/components/AppSettings.tsx | 73 +++- packages/desktop-app/src/renderer/global.d.ts | 14 + plans/desktop-sso-intent/work-ledger.md | 95 +++++ 37 files changed, 1232 insertions(+), 3 deletions(-) create mode 100644 .changeset/calm-desktops-federate.md create mode 100644 packages/desktop-app/src/main/desktop-identity.spec.ts create mode 100644 packages/desktop-app/src/main/desktop-identity.ts create mode 100644 plans/desktop-sso-intent/work-ledger.md diff --git a/.agents/skills/authentication/SKILL.md b/.agents/skills/authentication/SKILL.md index f7e5d4ef67..15f14105ab 100644 --- a/.agents/skills/authentication/SKILL.md +++ b/.agents/skills/authentication/SKILL.md @@ -95,6 +95,24 @@ Each hosted `*.agent-native.com` app has its **own user store**, so "sign in onc - **Invariant (do not break):** identity rows are only ever **added** — never modified, renamed, or deleted. Enabling SSO logs users out, but they always log back into the **same email-matched account with data intact**. Email is the only thing that crosses the trust boundary; the app never trusts a user id, role, or org from the wire. - **Canary rollout:** deploy with the env unset everywhere (no-op) → set it on **one** app (mail) only → verify (logout → SSO → Dispatch → back to the same pre-existing account, data intact, direct logins still work) → expand app-by-app → rollback = unset the env on that app's deploy (instant, no data change). +### Packaged Desktop workspace SSO + +For the signed Desktop app's canonical first-party hosted registry, Electron +owns a dedicated persistent Dispatch identity partition. Each target app still +uses the hosted federation flow above to mint its own normal local session; +Electron transfers only that target app's session cookie into its isolated +`persist:app-` partition. Never distribute a Dispatch cookie, an identity +JWT, or one app's session to another app, and never expose those values through +renderer IPC. + +Custom, user-added, and third-party apps are excluded. Preserve direct web +sign-in and the per-app `AGENT_NATIVE_IDENTITY_HUB_URL` opt-in/canary behavior. +The `desktop-sso.json` broker is only a loopback, non-production local +development compatibility path, not packaged hosted SSO. Desktop workspace +sign-out clears the central identity session and canonical app sessions; +standalone web logout remains app-local. Builder Connect and Builder credentials +are unrelated to human identity and must not join this flow. + Full runbook + flow detail: [Cross-App SSO doc](/docs/cross-app-sso). ## Builder Browser Access diff --git a/.changeset/calm-desktops-federate.md b/.changeset/calm-desktops-federate.md new file mode 100644 index 0000000000..2d1c6bb2b6 --- /dev/null +++ b/.changeset/calm-desktops-federate.md @@ -0,0 +1,5 @@ +--- +"@agent-native/core": minor +--- + +Add an authenticated, nonce-only completion route for packaged Desktop clients orchestrating cross-app identity federation. diff --git a/packages/core/docs/content/authentication.mdx b/packages/core/docs/content/authentication.mdx index c45c0015ab..76f1f1b16e 100644 --- a/packages/core/docs/content/authentication.mdx +++ b/packages/core/docs/content/authentication.mdx @@ -139,6 +139,29 @@ back to the shared `an_session` name. To intentionally share one auth database across subdomains, set `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` alongside `COOKIE_DOMAIN`. +## Packaged Desktop Workspace SSO {#desktop-workspace-sso} + +The signed Agent Native Desktop app gives canonical first-party hosted apps one +workspace sign-in without turning their sessions into a shared cookie. Desktop +keeps the Dispatch identity session in its own persistent Electron partition, +then each app completes the normal [Cross-App SSO](/docs/cross-app-sso) +federation and mints its **own** local session. Desktop transfers only that +target app's session cookie into its `persist:app-` partition. + +This applies only to the canonical first-party app registry packaged with +Desktop. Custom, user-added, and third-party apps are excluded. Direct web +sign-in remains available for recovery and standalone browser use, and every +hosted app still opts into federation independently with +`AGENT_NATIVE_IDENTITY_HUB_URL`; the existing canary and rollback procedure is +unchanged. + +The local `desktop-sso.json` broker is a separate compatibility mechanism for +loopback, non-production Electron development. It is not a hosted SSO +credential source and must not be broadened to production. Desktop workspace +sign-out clears the central Dispatch identity session and the canonical app +sessions in Desktop; standalone-browser sessions remain app-local. Builder +Connect and Builder credentials are unrelated to this identity flow. + ## QA Accounts {#qa-accounts} Local development and tests skip signup email verification by default, so you diff --git a/packages/core/docs/content/cross-app-sso.mdx b/packages/core/docs/content/cross-app-sso.mdx index fb5544e573..c54d28ae76 100644 --- a/packages/core/docs/content/cross-app-sso.mdx +++ b/packages/core/docs/content/cross-app-sso.mdx @@ -239,6 +239,28 @@ The just-in-time link is a single decision keyed entirely on the verified email: +## Packaged Desktop workspace {#desktop-workspace} + +The signed Agent Native Desktop app composes this hosted federation into one +workspace sign-in for its canonical first-party hosted app registry. Electron +keeps the Dispatch authority session in a dedicated persistent identity +partition. For each target app, it runs this app-local federation flow and +copies only the completed target app's normal session cookie into that app's +isolated `persist:app-` partition. Each app still owns its session, +database, authorization, and data isolation; Desktop does not distribute a +Dispatch cookie or a shared app token. + +This boundary deliberately excludes custom, user-added, and third-party apps. +The local `desktop-sso.json` broker remains a loopback, non-production +development compatibility path; it is not used for packaged hosted SSO. +Workspace-wide Desktop sign-out clears the central identity session and the +canonical app sessions in Desktop, while standalone web sign-out remains +app-local. Builder Connect and Builder credentials do not participate. + +Desktop does not change federation eligibility: each hosted app must still set +`AGENT_NATIVE_IDENTITY_HUB_URL`, direct app login remains available, and the +one-app-at-a-time canary and env-unset rollback below still apply. + ## Self-hosting {#self-hosting} Any Dispatch deployment can serve as the identity hub — you are not limited to `dispatch.agent-native.com`. Set `AGENT_NATIVE_IDENTITY_HUB_URL` on each client app to point at your Dispatch instance: diff --git a/packages/core/docs/content/locales/ar-SA/authentication.mdx b/packages/core/docs/content/locales/ar-SA/authentication.mdx index bdc2a07a65..71bec00f55 100644 --- a/packages/core/docs/content/locales/ar-SA/authentication.mdx +++ b/packages/core/docs/content/locales/ar-SA/authentication.mdx @@ -139,6 +139,10 @@ description: "تكامل أفضل للمصادقة مع البريد الإلك عبر النطاقات الفرعية، قم بتعيين `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` جنبًا إلى جنب مع `COOKIE_DOMAIN`. +## SSO لمساحة عمل Desktop {#desktop-workspace-sso} + +يستخدم تطبيق Agent Native Desktop الموقّع قسم هوية Dispatch دائمًا ومخصصًا للتطبيقات المستضافة الأولى الأساسية. ينفذ كل تطبيق اتحاد [Cross-App SSO](/docs/cross-app-sso) المعتاد وينشئ جلسته المحلية الخاصة؛ وينقل Desktop ملف تعريف ارتباط جلسة التطبيق الهدف فقط إلى `persist:app-`. تستبعد التطبيقات المخصصة والمضافة من المستخدم والطرف الثالث. لا يتغير تسجيل الدخول المباشر للويب ولا الاشتراك التدريجي وcanary والتراجع لكل تطبيق عبر `AGENT_NATIVE_IDENTITY_HUB_URL`. وسيط `desktop-sso.json` المحلي مخصص فقط لتطوير Electron عبر loopback وخارج الإنتاج. يسجل خروج Desktop بمسح الهوية المركزية وجلسات التطبيقات الأساسية، لا جلسات المتصفح؛ ولا علاقة لـ Builder Connect بذلك. + ## حسابات ضمان الجودة {#qa-accounts} تتخطى عمليات التطوير والاختبارات المحلية التحقق من البريد الإلكتروني للتسجيل بشكل افتراضي، لذا diff --git a/packages/core/docs/content/locales/ar-SA/cross-app-sso.mdx b/packages/core/docs/content/locales/ar-SA/cross-app-sso.mdx index bf6d56debb..3ea3f4cf4a 100644 --- a/packages/core/docs/content/locales/ar-SA/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/ar-SA/cross-app-sso.mdx @@ -239,6 +239,10 @@ AGENT_NATIVE_IDENTITY_HUB_URL=https://dispatch.agent-native.com +## مساحة عمل Desktop المعبأة {#desktop-workspace} + +يؤلف Desktop الموقّع هذا الاتحاد في تسجيل دخول لمساحة العمل لسجله الأساسي: يبقى قسم هوية Dispatch الدائم والمخصص مركزيًا، لكن كل تطبيق هدف ينشئ جلسته المحلية ولا ينقل إلا ملف تعريف ارتباطها إلى `persist:app-`. لا تتم مشاركة ملفات تعريف ارتباط Dispatch أو رموز التطبيقات. تستبعد التطبيقات المخصصة والمضافة من المستخدم والطرف الثالث. يظل `desktop-sso.json` وسيطًا محليًا للـ loopback خارج الإنتاج. يسجل خروج Desktop بمسح الهوية المركزية وجلسات التطبيقات الأساسية، ويبقى خروج المتصفح محليًا لكل تطبيق. Builder Connect غير ذي صلة. يستمر تسجيل الدخول المباشر والاشتراك التدريجي وcanary والتراجع بمتغير البيئة لكل تطبيق. + ## الاستضافة الذاتية {#self-hosting} يمكن أن يكون أي نشر لـ Dispatch بمثابة مركز الهوية - فأنت لست مقيدًا بـ `dispatch.agent-native.com`. قم بتعيين `AGENT_NATIVE_IDENTITY_HUB_URL` على كل تطبيق عميل للإشارة إلى مثيل Dispatch الخاص بك: diff --git a/packages/core/docs/content/locales/de-DE/authentication.mdx b/packages/core/docs/content/locales/de-DE/authentication.mdx index e074a04e7d..a46ec054e0 100644 --- a/packages/core/docs/content/locales/de-DE/authentication.mdx +++ b/packages/core/docs/content/locales/de-DE/authentication.mdx @@ -139,6 +139,10 @@ zurück zum gemeinsamen `an_session`-Namen. Absichtlich eine Authentifizierungsd über Subdomains hinweg `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` daneben setzen `COOKIE_DOMAIN`. +## Workspace-SSO in der Desktop-App {#desktop-workspace-sso} + +Die signierte Agent Native Desktop-App verwendet für kanonische gehostete First-Party-Apps eine eigene dauerhafte Dispatch-Identitätspartition. Jede App führt die normale [Cross-App SSO](/docs/cross-app-sso)-Föderation aus und prägt ihre eigene lokale Sitzung; Desktop überträgt nur das Sitzungscookie der Ziel-App nach `persist:app-`. Benutzerdefinierte, hinzugefügte und Drittanbieter-Apps sind ausgeschlossen. Direkte Web-Anmeldung sowie das Opt-in, Canary und Rollback pro App mit `AGENT_NATIVE_IDENTITY_HUB_URL` bleiben unverändert. Der lokale Broker `desktop-sso.json` gilt nur für Loopback- und Nicht-Produktions-Entwicklung. Desktop-Abmeldung löscht die zentrale Identität und kanonische App-Sitzungen, nicht Browser-Sitzungen; Builder Connect ist unabhängig. + ## QA-Konten {#qa-accounts} Lokale Entwicklung und Tests überspringen standardmäßig die Überprüfung der Anmelde-E-Mail, damit Sie diff --git a/packages/core/docs/content/locales/de-DE/cross-app-sso.mdx b/packages/core/docs/content/locales/de-DE/cross-app-sso.mdx index 1982638166..00e6d5e9f3 100644 --- a/packages/core/docs/content/locales/de-DE/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/de-DE/cross-app-sso.mdx @@ -239,6 +239,10 @@ Der Just-in-Time-Link ist eine einzelne Entscheidung, die vollständig auf der v +## Paketierter Desktop-Arbeitsbereich {#desktop-workspace} + +Die signierte Desktop-App verbindet diese Föderation für ihre kanonische First-Party-Registry zu einer Arbeitsbereich-Anmeldung: Eine dedizierte dauerhafte Dispatch-Identitätspartition bleibt zentral, jede Ziel-App prägt aber ihre eigene lokale Sitzung und nur deren Cookie wird nach `persist:app-` übertragen. Dispatch-Cookies und App-Tokens werden nicht geteilt. Benutzerdefinierte, hinzugefügte und Drittanbieter-Apps sind ausgeschlossen. `desktop-sso.json` bleibt ein lokaler Loopback-Nichtproduktions-Broker. Desktop-Abmeldung löscht zentrale Identität und kanonische App-Sitzungen, Browser-Abmeldung bleibt app-lokal; Builder Connect ist unabhängig. Direktlogin sowie Opt-in, Canary und Env-Unset-Rollback pro App bleiben bestehen. + ## Selbsthosting {#self-hosting} Jede Dispatch-Bereitstellung kann als Identitäts-Hub dienen – Sie sind nicht auf `dispatch.agent-native.com` beschränkt. Legen Sie `AGENT_NATIVE_IDENTITY_HUB_URL` in jeder Client-App so fest, dass es auf Ihre Dispatch-Instanz verweist: diff --git a/packages/core/docs/content/locales/es-ES/authentication.mdx b/packages/core/docs/content/locales/es-ES/authentication.mdx index d756f660bb..2bfbb21059 100644 --- a/packages/core/docs/content/locales/es-ES/authentication.mdx +++ b/packages/core/docs/content/locales/es-ES/authentication.mdx @@ -139,6 +139,10 @@ volver al nombre compartido `an_session`. Compartir intencionalmente una base de en todos los subdominios, establezca `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` al lado `COOKIE_DOMAIN`. +## SSO del espacio de trabajo de Desktop {#desktop-workspace-sso} + +La aplicación Desktop firmada mantiene una partición persistente de identidad de Dispatch para las apps alojadas propias canónicas. Cada app realiza la federación normal de [Cross-App SSO](/docs/cross-app-sso) y crea su propia sesión local; Desktop transfiere solamente la cookie de sesión de la app destino a `persist:app-`. Se excluyen apps personalizadas, añadidas por usuarios y de terceros. El inicio de sesión web directo y el opt-in, canary y reversión por app con `AGENT_NATIVE_IDENTITY_HUB_URL` no cambian. El broker local `desktop-sso.json` es solo para desarrollo Electron en loopback y no producción. Cerrar sesión en Desktop borra la identidad central y sesiones de apps canónicas, no sesiones del navegador; Builder Connect no está relacionado. + ## Cuentas de control de calidad {#qa-accounts} Las pruebas y el desarrollo local omiten la verificación del correo electrónico de registro de forma predeterminada, por lo que diff --git a/packages/core/docs/content/locales/es-ES/cross-app-sso.mdx b/packages/core/docs/content/locales/es-ES/cross-app-sso.mdx index adc89e443a..145ce2423e 100644 --- a/packages/core/docs/content/locales/es-ES/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/es-ES/cross-app-sso.mdx @@ -239,6 +239,10 @@ El enlace justo a tiempo es una decisión única basada completamente en el corr +## Espacio de trabajo Desktop empaquetado {#desktop-workspace} + +La aplicación Desktop firmada compone esta federación en un inicio de sesión de espacio de trabajo para su registro propio canónico: una partición persistente de identidad de Dispatch permanece central, pero cada app destino crea su propia sesión local y solo su cookie se transfiere a `persist:app-`. No se comparten cookies de Dispatch ni tokens de apps. Se excluyen apps personalizadas, añadidas por usuarios y de terceros. `desktop-sso.json` sigue siendo un broker local de loopback no productivo. Cerrar sesión en Desktop borra la identidad central y sesiones canónicas; en navegador sigue siendo local a cada app. Builder Connect no está relacionado. Se conservan el login directo y el opt-in, canary y rollback por variable de entorno por app. + ## Autohospedaje {#self-hosting} Cualquier implementación de Dispatch puede servir como centro de identidad; no está limitado a `dispatch.agent-native.com`. Configure `AGENT_NATIVE_IDENTITY_HUB_URL` en cada aplicación cliente para que apunte a su instancia de Dispatch: diff --git a/packages/core/docs/content/locales/fr-FR/authentication.mdx b/packages/core/docs/content/locales/fr-FR/authentication.mdx index 733fba9479..1679b98461 100644 --- a/packages/core/docs/content/locales/fr-FR/authentication.mdx +++ b/packages/core/docs/content/locales/fr-FR/authentication.mdx @@ -139,6 +139,10 @@ retour au nom `an_session` partagé. Pour partager intentionnellement une base d dans les sous-domaines, définissez `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` à côté `COOKIE_DOMAIN`. +## SSO de l’espace de travail Desktop {#desktop-workspace-sso} + +L’application Desktop signée conserve une partition d’identité Dispatch persistante pour les applications hébergées propriétaires canoniques. Chaque application exécute la fédération [Cross-App SSO](/docs/cross-app-sso) normale et crée sa propre session locale ; Desktop ne transfère que le cookie de session de l’application cible vers `persist:app-`. Les applications personnalisées, ajoutées par l’utilisateur et tierces sont exclues. La connexion web directe ainsi que l’opt-in, le canari et le retour arrière par application avec `AGENT_NATIVE_IDENTITY_HUB_URL` restent inchangés. Le broker local `desktop-sso.json` est réservé au développement Electron en loopback hors production. La déconnexion Desktop efface l’identité centrale et les sessions canoniques, pas celles du navigateur ; Builder Connect est sans rapport. + ## Comptes d'assurance qualité {#qa-accounts} Le développement et les tests locaux ignorent la vérification de l'e-mail d'inscription par défaut, vous pouvez donc diff --git a/packages/core/docs/content/locales/fr-FR/cross-app-sso.mdx b/packages/core/docs/content/locales/fr-FR/cross-app-sso.mdx index 1b7b4c7c75..c05f4b165c 100644 --- a/packages/core/docs/content/locales/fr-FR/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/fr-FR/cross-app-sso.mdx @@ -239,6 +239,10 @@ Le lien juste à temps est une décision unique entièrement saisie sur l’e-ma +## Espace de travail Desktop empaqueté {#desktop-workspace} + +L’application Desktop signée compose cette fédération en une connexion d’espace de travail pour son registre propriétaire canonique : une partition d’identité Dispatch persistante reste centrale, mais chaque application cible crée sa propre session locale et seul son cookie est transféré vers `persist:app-`. Aucun cookie Dispatch ni jeton d’application n’est partagé. Les applications personnalisées, ajoutées par l’utilisateur et tierces sont exclues. `desktop-sso.json` reste un broker local loopback hors production. La déconnexion Desktop efface l’identité centrale et les sessions canoniques, celle du navigateur reste locale à l’application ; Builder Connect est sans rapport. La connexion directe et l’opt-in, canari et retour arrière par variable d’environnement restent disponibles. + ## Auto-hébergement {#self-hosting} Tout déploiement Dispatch peut servir de centre d'identité ; vous n'êtes pas limité à `dispatch.agent-native.com`. Définissez `AGENT_NATIVE_IDENTITY_HUB_URL` sur chaque application client pour pointer vers votre instance Dispatch : diff --git a/packages/core/docs/content/locales/hi-IN/authentication.mdx b/packages/core/docs/content/locales/hi-IN/authentication.mdx index 20b9d38ee3..627d2dd21b 100644 --- a/packages/core/docs/content/locales/hi-IN/authentication.mdx +++ b/packages/core/docs/content/locales/hi-IN/authentication.mdx @@ -139,6 +139,10 @@ description: "ईमेल/पासवर्ड, सामाजिक प् उपडोमेन में, `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` को साथ में सेट करें `COOKIE_DOMAIN`. +## Desktop कार्यक्षेत्र SSO {#desktop-workspace-sso} + +हस्ताक्षरित Agent Native Desktop, canonical first-party hosted apps के लिए एक समर्पित persistent Dispatch identity partition रखता है। हर app सामान्य [Cross-App SSO](/docs/cross-app-sso) federation चलाकर अपना local session बनाती है; Desktop केवल target app का session cookie `persist:app-` में भेजता है। custom, user-added और third-party apps बाहर हैं। direct web sign-in और `AGENT_NATIVE_IDENTITY_HUB_URL` के प्रति-app opt-in, canary तथा rollback नहीं बदलते। स्थानीय `desktop-sso.json` broker केवल loopback, non-production Electron development के लिए है। Desktop sign-out केंद्रीय identity और canonical app sessions मिटाता है, browser sessions नहीं; Builder Connect असंबंधित है। + ## क्यूए खाते {#qa-accounts} स्थानीय विकास और परीक्षण डिफ़ॉल्ट रूप से साइनअप ईमेल सत्यापन को छोड़ देते हैं, इसलिए आप diff --git a/packages/core/docs/content/locales/hi-IN/cross-app-sso.mdx b/packages/core/docs/content/locales/hi-IN/cross-app-sso.mdx index 2acddf40ee..ff49f2c35d 100644 --- a/packages/core/docs/content/locales/hi-IN/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/hi-IN/cross-app-sso.mdx @@ -239,6 +239,10 @@ AGENT_NATIVE_IDENTITY_HUB_URL=https://dispatch.agent-native.com +## पैकेज्ड Desktop कार्यक्षेत्र {#desktop-workspace} + +हस्ताक्षरित Desktop इस federation को अपनी canonical first-party registry के लिए एक कार्यक्षेत्र sign-in में जोड़ता है। समर्पित persistent Dispatch identity partition केंद्रीय रहता है, लेकिन हर target app अपना local session बनाती है और केवल उसका cookie `persist:app-` में जाता है। Dispatch cookies या app tokens साझा नहीं होते। custom, user-added और third-party apps बाहर हैं। `desktop-sso.json` loopback, non-production local broker ही रहता है। Desktop sign-out केंद्रीय identity और canonical app sessions मिटाता है, browser sign-out app-local रहता है। Builder Connect असंबंधित है। direct login और प्रति-app env opt-in, canary तथा rollback कायम रहते हैं। + ## स्वयं-होस्टिंग {#self-hosting} कोई भी डिस्पैच परिनियोजन पहचान केंद्र के रूप में काम कर सकता है - आप `dispatch.agent-native.com` तक सीमित नहीं हैं। अपने डिस्पैच इंस्टेंस को इंगित करने के लिए प्रत्येक क्लाइंट ऐप पर `AGENT_NATIVE_IDENTITY_HUB_URL` सेट करें: diff --git a/packages/core/docs/content/locales/ja-JP/authentication.mdx b/packages/core/docs/content/locales/ja-JP/authentication.mdx index c20bec07df..2fed206246 100644 --- a/packages/core/docs/content/locales/ja-JP/authentication.mdx +++ b/packages/core/docs/content/locales/ja-JP/authentication.mdx @@ -139,6 +139,10 @@ Better Auth ルートは `/_agent-native/auth/ba/*` にマウントされます サブドメイン全体で、`AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` を横に設定します `COOKIE_DOMAIN`. +## Desktop ワークスペース SSO {#desktop-workspace-sso} + +署名済み Agent Native Desktop は、正規のファーストパーティー ホストアプリ向けに永続的な Dispatch ID パーティションを使用します。各アプリは通常の [Cross-App SSO](/docs/cross-app-sso) フェデレーションを実行して独自のローカル セッションを発行し、Desktop は対象アプリのセッション Cookie だけを `persist:app-` に転送します。カスタム、ユーザー追加、サードパーティーのアプリは対象外です。直接 Web サインインと、`AGENT_NATIVE_IDENTITY_HUB_URL` によるアプリごとの opt-in、canary、ロールバックは変わりません。ローカルの `desktop-sso.json` broker は loopback の非本番 Electron 開発専用です。Desktop のサインアウトは中央 ID と正規アプリのセッションを消去しますが、ブラウザーのセッションは消去しません。Builder Connect は無関係です。 + ## QA アカウント {#qa-accounts} ローカル開発とテストでは、デフォルトでサインアップ電子メールの検証がスキップされるため、 diff --git a/packages/core/docs/content/locales/ja-JP/cross-app-sso.mdx b/packages/core/docs/content/locales/ja-JP/cross-app-sso.mdx index d51af5889b..5ae96f2b68 100644 --- a/packages/core/docs/content/locales/ja-JP/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/ja-JP/cross-app-sso.mdx @@ -239,6 +239,10 @@ AGENT_NATIVE_IDENTITY_HUB_URL=https://dispatch.agent-native.com +## パッケージ版 Desktop ワークスペース {#desktop-workspace} + +署名済み Desktop は、このフェデレーションを正規ファーストパーティー レジストリのワークスペース サインインとして構成します。専用の永続 Dispatch ID パーティションは中央に残りますが、各対象アプリは独自のローカル セッションを発行し、その Cookie だけが `persist:app-` に転送されます。Dispatch Cookie やアプリ トークンは共有しません。カスタム、ユーザー追加、サードパーティーのアプリは対象外です。`desktop-sso.json` は loopback 非本番ローカル broker のままです。Desktop サインアウトは中央 ID と正規アプリ セッションを消去し、ブラウザー サインアウトはアプリ単位のままです。Builder Connect は無関係です。直接ログインとアプリごとの環境変数 opt-in、canary、rollback は維持されます。 + ## セルフホスティング {#self-hosting} あらゆる Dispatch デプロイメントが ID ハブとして機能できます。`dispatch.agent-native.com` に限定されません。 Dispatch インスタンスを指すように各クライアント アプリで `AGENT_NATIVE_IDENTITY_HUB_URL` を設定します。 diff --git a/packages/core/docs/content/locales/ko-KR/authentication.mdx b/packages/core/docs/content/locales/ko-KR/authentication.mdx index 7d3ea00355..5ee07d61e3 100644 --- a/packages/core/docs/content/locales/ko-KR/authentication.mdx +++ b/packages/core/docs/content/locales/ko-KR/authentication.mdx @@ -139,6 +139,10 @@ description: "이메일/비밀번호, 소셜 공급자, 조직 및 MCP 보유자 하위 도메인 전반에 걸쳐 `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1`를 나란히 설정 `COOKIE_DOMAIN`. +## Desktop 작업공간 SSO {#desktop-workspace-sso} + +서명된 Agent Native Desktop은 정식 퍼스트파티 호스팅 앱에 전용 영구 Dispatch ID 파티션을 사용합니다. 각 앱은 일반 [Cross-App SSO](/docs/cross-app-sso) 페더레이션을 실행하고 자체 로컬 세션을 만들며, Desktop은 대상 앱의 세션 쿠키만 `persist:app-`로 옮깁니다. 사용자 정의, 사용자가 추가한 앱 및 타사 앱은 제외됩니다. 직접 웹 로그인과 `AGENT_NATIVE_IDENTITY_HUB_URL`의 앱별 opt-in, canary, 롤백은 바뀌지 않습니다. 로컬 `desktop-sso.json` broker는 loopback 비프로덕션 Electron 개발 전용입니다. Desktop 로그아웃은 중앙 ID와 정식 앱 세션을 지우지만 브라우저 세션은 지우지 않습니다. Builder Connect는 관련이 없습니다. + ## QA 계정 {#qa-accounts} 로컬 개발 및 테스트에서는 기본적으로 가입 이메일 확인을 건너뛰므로 diff --git a/packages/core/docs/content/locales/ko-KR/cross-app-sso.mdx b/packages/core/docs/content/locales/ko-KR/cross-app-sso.mdx index 087cc46ab2..ca136ee10f 100644 --- a/packages/core/docs/content/locales/ko-KR/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/ko-KR/cross-app-sso.mdx @@ -239,6 +239,10 @@ AGENT_NATIVE_IDENTITY_HUB_URL=https://dispatch.agent-native.com +## 패키지된 Desktop 작업공간 {#desktop-workspace} + +서명된 Desktop은 이 페더레이션을 정식 퍼스트파티 레지스트리용 작업공간 로그인으로 구성합니다. 전용 영구 Dispatch ID 파티션은 중앙에 남지만 각 대상 앱은 자체 로컬 세션을 만들고 그 쿠키만 `persist:app-`로 옮깁니다. Dispatch 쿠키나 앱 토큰은 공유하지 않습니다. 사용자 정의, 사용자가 추가한 앱 및 타사 앱은 제외됩니다. `desktop-sso.json`은 loopback 비프로덕션 로컬 broker로 남습니다. Desktop 로그아웃은 중앙 ID와 정식 앱 세션을 지우며 브라우저 로그아웃은 앱별로 남습니다. Builder Connect는 관련이 없습니다. 직접 로그인과 앱별 환경 변수 opt-in, canary, rollback은 유지됩니다. + ## 자체 호스팅 {#self-hosting} 모든 Dispatch 배포는 ID 허브 역할을 할 수 있습니다. `dispatch.agent-native.com`로 제한되지 않습니다. Dispatch 인스턴스를 가리키도록 각 클라이언트 앱의 `AGENT_NATIVE_IDENTITY_HUB_URL`를 설정하세요. diff --git a/packages/core/docs/content/locales/pt-BR/authentication.mdx b/packages/core/docs/content/locales/pt-BR/authentication.mdx index 385b367c23..d1105fd968 100644 --- a/packages/core/docs/content/locales/pt-BR/authentication.mdx +++ b/packages/core/docs/content/locales/pt-BR/authentication.mdx @@ -139,6 +139,10 @@ de volta ao nome `an_session` compartilhado. Para compartilhar intencionalmente em todos os subdomínios, defina `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` ao lado `COOKIE_DOMAIN`. +## SSO do espaço de trabalho Desktop {#desktop-workspace-sso} + +O Desktop assinado mantém uma partição persistente de identidade do Dispatch para os apps hospedados próprios canônicos. Cada app executa a federação normal de [Cross-App SSO](/docs/cross-app-sso) e cria sua própria sessão local; o Desktop transfere apenas o cookie de sessão do app de destino para `persist:app-`. Apps personalizados, adicionados pelo usuário e de terceiros são excluídos. O login web direto e o opt-in, canary e rollback por app com `AGENT_NATIVE_IDENTITY_HUB_URL` não mudam. O broker local `desktop-sso.json` é apenas para desenvolvimento Electron em loopback, fora de produção. Sair do Desktop limpa a identidade central e as sessões dos apps canônicos, não as sessões do navegador; Builder Connect não tem relação. + ## Contas de controle de qualidade {#qa-accounts} O desenvolvimento local e os testes ignoram a verificação de e-mail de inscrição por padrão, então você diff --git a/packages/core/docs/content/locales/pt-BR/cross-app-sso.mdx b/packages/core/docs/content/locales/pt-BR/cross-app-sso.mdx index 6293c84ced..bf254646fa 100644 --- a/packages/core/docs/content/locales/pt-BR/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/pt-BR/cross-app-sso.mdx @@ -239,6 +239,10 @@ O link just-in-time é uma decisão única digitada inteiramente no e-mail verif +## Espaço de trabalho Desktop empacotado {#desktop-workspace} + +O Desktop assinado transforma esta federação em um login de espaço de trabalho para seu registro próprio canônico: uma partição persistente de identidade Dispatch fica central, mas cada app de destino cria sua própria sessão local e somente seu cookie é transferido para `persist:app-`. Cookies do Dispatch e tokens de apps não são compartilhados. Apps personalizados, adicionados pelo usuário e de terceiros são excluídos. `desktop-sso.json` continua sendo um broker local de loopback fora de produção. Sair do Desktop limpa a identidade central e sessões canônicas; no navegador o logout continua local ao app. Builder Connect não tem relação. Login direto e opt-in, canary e rollback por variável de ambiente por app permanecem disponíveis. + ## Auto-hospedagem {#self-hosting} Qualquer implantação do Dispatch pode servir como hub de identidade — você não está limitado ao `dispatch.agent-native.com`. Defina `AGENT_NATIVE_IDENTITY_HUB_URL` em cada aplicativo cliente para apontar para sua instância do Dispatch: diff --git a/packages/core/docs/content/locales/zh-CN/authentication.mdx b/packages/core/docs/content/locales/zh-CN/authentication.mdx index ba6b2bf384..212d9065eb 100644 --- a/packages/core/docs/content/locales/zh-CN/authentication.mdx +++ b/packages/core/docs/content/locales/zh-CN/authentication.mdx @@ -139,6 +139,10 @@ Better Auth 路由安装在 `/_agent-native/auth/ba/*`。该框架还提供向 跨子域,将 `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` 并排设置 `COOKIE_DOMAIN`. +## Desktop 工作区 SSO {#desktop-workspace-sso} + +已签名的 Agent Native Desktop 为规范的一方托管应用保留专用的持久 Dispatch 身份分区。每个应用运行正常的 [Cross-App SSO](/docs/cross-app-sso) 联合并创建自己的本地会话;Desktop 只将目标应用的会话 cookie 传入 `persist:app-`。自定义、用户添加和第三方应用均被排除。直接网页登录以及通过 `AGENT_NATIVE_IDENTITY_HUB_URL` 进行的逐应用 opt-in、canary 和回滚保持不变。本地 `desktop-sso.json` broker 仅用于 loopback、非生产 Electron 开发。Desktop 退出会清除中央身份和规范应用会话,不会清除浏览器会话;Builder Connect 与此无关。 + ## 质量检查帐户 {#qa-accounts} 本地开发和测试默认跳过注册电子邮件验证,因此您 diff --git a/packages/core/docs/content/locales/zh-CN/cross-app-sso.mdx b/packages/core/docs/content/locales/zh-CN/cross-app-sso.mdx index bb08be0db3..a10836e7d6 100644 --- a/packages/core/docs/content/locales/zh-CN/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/zh-CN/cross-app-sso.mdx @@ -237,6 +237,10 @@ AGENT_NATIVE_IDENTITY_HUB_URL=https://dispatch.agent-native.com +## 打包的 Desktop 工作区 {#desktop-workspace} + +已签名的 Desktop 将此联合编排为其规范一方注册表的工作区登录。专用的持久 Dispatch 身份分区保持在中央,但每个目标应用创建自己的本地会话,且只有该 cookie 被传入 `persist:app-`。不会共享 Dispatch cookie 或应用 token。自定义、用户添加和第三方应用被排除。`desktop-sso.json` 仍只是 loopback、非生产本地 broker。Desktop 退出会清除中央身份和规范应用会话;浏览器退出仍限于应用本身。Builder Connect 无关。直接登录及逐应用环境变量 opt-in、canary 和回滚仍然可用。 + ## 自托管 {#self-hosting} 任何 Dispatch 部署都可以充当身份中心 - 您不限于 `dispatch.agent-native.com`。在每个客户端应用程序上设置 `AGENT_NATIVE_IDENTITY_HUB_URL` 以指向您的 Dispatch 实例: diff --git a/packages/core/docs/content/locales/zh-TW/authentication.mdx b/packages/core/docs/content/locales/zh-TW/authentication.mdx index 9f5e9057a5..52becd0da9 100644 --- a/packages/core/docs/content/locales/zh-TW/authentication.mdx +++ b/packages/core/docs/content/locales/zh-TW/authentication.mdx @@ -139,6 +139,10 @@ Better Auth 路由安裝在 `/_agent-native/auth/ba/*`。此框架還提供向 跨子域,將 `AGENT_NATIVE_SHARE_COOKIE_DOMAIN=1` 並排設定 `COOKIE_DOMAIN`. +## Desktop 工作區 SSO {#desktop-workspace-sso} + +已簽署的 Agent Native Desktop 會為規範的第一方託管應用程式保留專用且持久的 Dispatch 身分分割區。每個應用程式執行一般的 [Cross-App SSO](/docs/cross-app-sso) 聯合並建立自己的本機工作階段;Desktop 只會將目標應用程式的工作階段 cookie 傳入 `persist:app-`。自訂、使用者新增及第三方應用程式均不包含在內。直接網頁登入,以及透過 `AGENT_NATIVE_IDENTITY_HUB_URL` 的逐一應用程式 opt-in、canary 和復原維持不變。本機 `desktop-sso.json` broker 僅適用於 loopback、非正式環境的 Electron 開發。Desktop 登出會清除中央身分及規範應用程式工作階段,不會清除瀏覽器工作階段;Builder Connect 與此無關。 + ## 品質檢查帳戶 {#qa-accounts} 本機開發和測試預設跳過註冊電子郵件驗證,因此您 diff --git a/packages/core/docs/content/locales/zh-TW/cross-app-sso.mdx b/packages/core/docs/content/locales/zh-TW/cross-app-sso.mdx index 62c8241be9..62e04178f5 100644 --- a/packages/core/docs/content/locales/zh-TW/cross-app-sso.mdx +++ b/packages/core/docs/content/locales/zh-TW/cross-app-sso.mdx @@ -237,6 +237,10 @@ AGENT_NATIVE_IDENTITY_HUB_URL=https://dispatch.agent-native.com +## 封裝的 Desktop 工作區 {#desktop-workspace} + +已簽署的 Desktop 將此聯合編排為其規範第一方登錄檔的工作區登入。專用且持久的 Dispatch 身分分割區會保留在中央,但每個目標應用程式建立自己的本機工作階段,且只有該 cookie 會傳入 `persist:app-`。不會共用 Dispatch cookie 或應用程式 token。自訂、使用者新增和第三方應用程式均排除在外。`desktop-sso.json` 仍只是 loopback、非正式環境的本機 broker。Desktop 登出會清除中央身分及規範應用程式工作階段;瀏覽器登出仍只限於該應用程式。Builder Connect 無關。直接登入及逐一應用程式環境變數 opt-in、canary 和復原仍可使用。 + ## 自託管 {#self-hosting} 任何 Dispatch 部署都可以充當身分中心 - 您不限於 `dispatch.agent-native.com`。在每個用戶端應用程式上設定 `AGENT_NATIVE_IDENTITY_HUB_URL` 以指向您的 Dispatch 執行個體: diff --git a/packages/core/src/server/core-routes-plugin.ts b/packages/core/src/server/core-routes-plugin.ts index 4e827d9a6c..6975b16be1 100644 --- a/packages/core/src/server/core-routes-plugin.ts +++ b/packages/core/src/server/core-routes-plugin.ts @@ -3936,6 +3936,8 @@ export function createCoreRoutesPlugin( // auth byte-for-byte unchanged. `/login` 302s to the identity hub; // `/callback` verifies the hub-issued A2A-signed identity JWT and JIT- // links the verified email into this app's local Better Auth store. The + // authenticated `/desktop-complete` landing page gives Electron a + // nonce-only completion signal after that local session exists. The // handler 404s if disabled (defence in depth). The auth guard bypasses // these two exact paths under the same env gate. if (isIdentitySsoEnabled()) { diff --git a/packages/core/src/server/identity-sso.spec.ts b/packages/core/src/server/identity-sso.spec.ts index 981939b6b3..fcd9c92103 100644 --- a/packages/core/src/server/identity-sso.spec.ts +++ b/packages/core/src/server/identity-sso.spec.ts @@ -115,7 +115,8 @@ vi.mock("./identity-sso-store.js", () => ({ }), })); -const { handleIdentitySso } = await import("./identity-sso.js"); +const { handleIdentitySso, isIdentitySsoBypassPath } = + await import("./identity-sso.js"); const HUB = "https://dispatch.agent-native.com"; const SECRET = "test-a2a-secret"; @@ -221,6 +222,44 @@ describe("identity SSO — /login", () => { }); }); +describe("identity SSO — Desktop completion", () => { + const nonce = "desktop_completion_nonce_12345678901234567890"; + + it("requires a valid nonce and an authenticated app-local session", async () => { + expect( + ( + await handleIdentitySso( + ev({ path: "/desktop-complete?nonce=short" }), + "/desktop-complete", + ) + ).status, + ).toBe(400); + + const unauthenticated = await handleIdentitySso( + ev({ path: `/desktop-complete?nonce=${nonce}` }), + "/desktop-complete", + ); + expect(unauthenticated.status).toBe(401); + + getSessionMock.mockResolvedValue({ email: "example.user@example.com" }); + const authenticated = await handleIdentitySso( + ev({ path: `/desktop-complete?nonce=${nonce}` }), + "/desktop-complete", + ); + expect(authenticated.status).toBe(200); + expect(authenticated.headers.get("Cache-Control")).toBe("no-store"); + const body = await authenticated.text(); + expect(body).not.toContain(nonce); + expect(body).not.toContain("example.user@example.com"); + }); + + it("does not make the completion page an auth-guard bypass", () => { + expect( + isIdentitySsoBypassPath("/_agent-native/identity/desktop-complete"), + ).toBe(false); + }); +}); + describe("identity SSO — /callback rejects bad tokens", () => { async function mintState(returnPath: string | null = null): Promise { const store = await import("./identity-sso-store.js"); diff --git a/packages/core/src/server/identity-sso.ts b/packages/core/src/server/identity-sso.ts index 1c26c4e39b..acfa90ea97 100644 --- a/packages/core/src/server/identity-sso.ts +++ b/packages/core/src/server/identity-sso.ts @@ -83,6 +83,17 @@ export const IDENTITY_SSO_PROVIDER_ID = "agent-native"; */ export const IDENTITY_SSO_SCOPE = "identity"; +/** + * Same-origin landing page used by the packaged Desktop identity broker after + * this app has minted its ordinary local session. The page carries only a + * caller-generated nonce; Electron reads the session cookie from its private + * identity partition and never receives identity data from the document. + */ +export const IDENTITY_SSO_DESKTOP_COMPLETE_PATH = + "/_agent-native/identity/desktop-complete"; + +const DESKTOP_COMPLETION_NONCE = /^[A-Za-z0-9_-]{32,128}$/; + /** Identity tokens older than this are rejected even if `exp` is generous. */ const MAX_TOKEN_AGE_SECONDS = 10 * 60; @@ -494,6 +505,47 @@ export async function handleIdentitySso( return redirect(event, dest); } + // ---- GET /desktop-complete → inert authenticated landing page -------- + if (sub === "/desktop-complete") { + if (method !== "GET" && method !== "HEAD") { + return new Response("Method not allowed", { status: 405 }); + } + + let nonce = ""; + try { + const u = new URL( + (event as any).node?.req?.url ?? event.path ?? "/", + "http://an.invalid", + ); + nonce = u.searchParams.get("nonce") || ""; + } catch { + return new Response("Invalid completion request", { status: 400 }); + } + if (!DESKTOP_COMPLETION_NONCE.test(nonce)) { + return new Response("Invalid completion request", { status: 400 }); + } + + const current = await getSession(event).catch(() => null); + if (!current?.email) { + return new Response("Authentication required", { status: 401 }); + } + + return new Response( + '' + + '' + + "Signed inSigned in. You can close this window.", + { + status: 200, + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "Content-Security-Policy": "default-src 'none'; style-src 'none'", + "Referrer-Policy": "no-referrer", + }, + }, + ); + } + return new Response("Not found", { status: 404 }); } diff --git a/packages/core/src/server/index.ts b/packages/core/src/server/index.ts index 70e05d8c38..4f3f3c1cac 100644 --- a/packages/core/src/server/index.ts +++ b/packages/core/src/server/index.ts @@ -81,6 +81,7 @@ export { identitySsoLoginButtonHtml, IDENTITY_SSO_PROVIDER_ID, IDENTITY_SSO_SCOPE, + IDENTITY_SSO_DESKTOP_COMPLETE_PATH, } from "./identity-sso.js"; export { requireEnvKey, type MissingKeyResponse } from "./missing-key.js"; export { diff --git a/packages/desktop-app/shared/ipc-channels.ts b/packages/desktop-app/shared/ipc-channels.ts index 9b757a4db5..0735a0720a 100644 --- a/packages/desktop-app/shared/ipc-channels.ts +++ b/packages/desktop-app/shared/ipc-channels.ts @@ -26,6 +26,12 @@ export const IPC = { /** App status events (main → renderer) */ APP_STATUS: "app:status", + /** Desktop workspace identity (renderer intent/status only; no secrets) */ + IDENTITY_STATUS_GET: "identity:status:get", + IDENTITY_STATUS_CHANGED: "identity:status:changed", + IDENTITY_SIGN_IN: "identity:sign-in", + IDENTITY_SIGN_OUT: "identity:sign-out", + /** App config management (renderer ↔ main) */ APPS_LOAD: "apps:load", APPS_ADD: "apps:add", @@ -257,6 +263,13 @@ export type DesktopPlanFilesResult = folder?: DesktopPlanFilesFolder; }; +export type DesktopIdentityStatus = + | "idle" + | "signing-in" + | "signed-in" + | "sign-in-required" + | "failed"; + export interface DesktopContentFilesFolder { id?: string; name: string; diff --git a/packages/desktop-app/src/main/desktop-identity.spec.ts b/packages/desktop-app/src/main/desktop-identity.spec.ts new file mode 100644 index 0000000000..c6d045b5a0 --- /dev/null +++ b/packages/desktop-app/src/main/desktop-identity.spec.ts @@ -0,0 +1,233 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + DESKTOP_IDENTITY_COMPLETE_PATH, + DesktopIdentityBroker, + isDesktopIdentityCompletion, + isDesktopSignInNavigation, + isDesktopWorkspaceLogoutRequest, + type DesktopIdentityApp, +} from "./desktop-identity"; + +function cookieStore(initial: Electron.Cookie[] = []) { + const cookies = [...initial]; + return { + get: vi.fn(async () => [...cookies]), + set: vi.fn(async (cookie: Electron.CookiesSetDetails) => { + cookies.push({ + name: cookie.name!, + value: cookie.value!, + domain: new URL(cookie.url).hostname, + hostOnly: true, + path: cookie.path ?? "/", + secure: cookie.secure ?? true, + httpOnly: cookie.httpOnly ?? true, + session: !cookie.expirationDate, + sameSite: cookie.sameSite ?? "lax", + ...(cookie.expirationDate + ? { expirationDate: cookie.expirationDate } + : {}), + }); + }), + remove: vi.fn(async (_url: string, name: string) => { + const index = cookies.findIndex((cookie) => cookie.name === name); + if (index >= 0) cookies.splice(index, 1); + }), + }; +} + +function appFixture(): DesktopIdentityApp { + return { + id: "mail", + origin: "https://mail.agent-native.com", + cookieNames: ["an_session_mail", "an_session"], + cookieNamesToClear: [ + "an_session_mail", + "an_session", + "an_mail.session_token", + "__Secure-an_mail.session_token", + ], + session: { cookies: cookieStore() } as unknown as Electron.Session, + }; +} + +describe("Desktop identity navigation boundaries", () => { + const app = appFixture(); + + it("intercepts only the exact canonical app sign-in path", () => { + expect( + isDesktopSignInNavigation( + "https://mail.agent-native.com/_agent-native/sign-in?return=%2Finbox", + app, + ), + ).toBe(true); + expect( + isDesktopSignInNavigation( + "https://evil.example/_agent-native/sign-in", + app, + ), + ).toBe(false); + expect( + isDesktopSignInNavigation( + "https://mail.agent-native.com/_agent-native/identity/login", + app, + ), + ).toBe(false); + }); + + it("accepts completion only for the exact origin, path, and nonce", () => { + const nonce = "nonce_12345678901234567890123456789012"; + expect( + isDesktopIdentityCompletion( + `https://mail.agent-native.com${DESKTOP_IDENTITY_COMPLETE_PATH}?nonce=${nonce}`, + app, + nonce, + ), + ).toBe(true); + expect( + isDesktopIdentityCompletion( + `https://calendar.agent-native.com${DESKTOP_IDENTITY_COMPLETE_PATH}?nonce=${nonce}`, + app, + nonce, + ), + ).toBe(false); + expect( + isDesktopIdentityCompletion( + `https://mail.agent-native.com${DESKTOP_IDENTITY_COMPLETE_PATH}?nonce=stale`, + app, + nonce, + ), + ).toBe(false); + }); + + it("recognizes workspace logout only on the canonical app origin", () => { + expect( + isDesktopWorkspaceLogoutRequest( + "https://mail.agent-native.com/_agent-native/auth/logout", + app, + ), + ).toBe(true); + expect( + isDesktopWorkspaceLogoutRequest( + "https://evil.example/_agent-native/auth/logout", + app, + ), + ).toBe(false); + }); +}); + +describe("DesktopIdentityBroker", () => { + it("coalesces duplicate requests and copies only the target cookie", async () => { + const app = appFixture(); + const identityCookies = cookieStore([ + { + name: "an_session_mail", + value: "example-session-value", + domain: "mail.agent-native.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + session: false, + sameSite: "lax", + expirationDate: Date.now() / 1000 + 3600, + }, + { + name: "unrelated_cookie", + value: "do-not-copy", + domain: "mail.agent-native.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + session: true, + sameSite: "lax", + }, + ]); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + timeoutMs: 10_000, + }); + + const first = broker.ensureAppSession("mail"); + const second = broker.ensureAppSession("mail"); + expect(second).toBe(first); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + + const nonce = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(nonce, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "will-navigate", + )?.[1]; + const preventDefault = vi.fn(); + navigationHandler({ preventDefault }, completion); + + await expect(first).resolves.toBe(true); + expect(preventDefault).toHaveBeenCalled(); + expect(app.session.cookies.set).toHaveBeenCalledTimes(1); + expect(app.session.cookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + name: "an_session_mail", + value: "example-session-value", + }), + ); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(identityCookies.remove).toHaveBeenCalledWith( + app.origin, + "an_session_mail", + ); + expect(closedListener).toBeDefined(); + }); + + it("clears only canonical app cookies plus the central identity session", async () => { + const app = appFixture(); + const identitySession = { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session; + const clearLocalBroker = vi.fn(); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession, + resolveApp: () => app, + createWindow: vi.fn() as never, + reloadApp, + clearLocalBroker, + }); + + await broker.signOut([app]); + + expect(identitySession.clearStorageData).toHaveBeenCalledWith({ + storages: ["cookies"], + }); + expect(app.session.cookies.remove).toHaveBeenCalledTimes(4); + expect(clearLocalBroker).toHaveBeenCalledOnce(); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("sign-in-required"); + }); +}); diff --git a/packages/desktop-app/src/main/desktop-identity.ts b/packages/desktop-app/src/main/desktop-identity.ts new file mode 100644 index 0000000000..7c596a6030 --- /dev/null +++ b/packages/desktop-app/src/main/desktop-identity.ts @@ -0,0 +1,344 @@ +import { randomBytes } from "node:crypto"; + +import type { + BrowserWindow, + BrowserWindowConstructorOptions, + Session, + WebContents, + WindowOpenHandlerResponse, +} from "electron"; + +export const DESKTOP_IDENTITY_PARTITION = "persist:agent-native-identity"; +export const DESKTOP_IDENTITY_COMPLETE_PATH = + "/_agent-native/identity/desktop-complete"; + +const DESKTOP_SIGN_IN_PATH = "/_agent-native/sign-in"; +const DESKTOP_IDENTITY_LOGIN_PATH = "/_agent-native/identity/login"; +const DEFAULT_CEREMONY_TIMEOUT_MS = 5 * 60 * 1000; + +export type DesktopIdentityStatus = + | "idle" + | "signing-in" + | "signed-in" + | "sign-in-required" + | "failed"; + +export interface DesktopIdentityApp { + id: string; + origin: string; + session: Session; + cookieNames: string[]; + cookieNamesToClear: string[]; + identityAuthority?: boolean; +} + +export function isDesktopWorkspaceLogoutRequest( + requestUrl: string, + app: Pick, +): boolean { + try { + const parsed = new URL(requestUrl); + return ( + parsed.origin === app.origin && + (parsed.pathname === "/_agent-native/auth/logout" || + parsed.pathname === "/_agent-native/auth/logout-all") + ); + } catch { + return false; + } +} + +interface DesktopIdentityWindow { + webContents: WebContents; + loadURL(url: string): Promise; + isDestroyed(): boolean; + close(): void; + on(event: "closed", listener: () => void): unknown; +} + +export interface DesktopIdentityBrokerOptions { + identitySession: Session; + resolveApp: (appId: string) => DesktopIdentityApp | null; + createWindow: ( + options: BrowserWindowConstructorOptions, + ) => DesktopIdentityWindow; + parentWindow?: () => BrowserWindow | null; + handleWindowOpen?: ( + contents: WebContents, + url: string, + ) => WindowOpenHandlerResponse; + handleOAuthNavigation?: (url: string, contents: WebContents) => boolean; + reloadApp: (app: DesktopIdentityApp) => void; + clearLocalBroker: () => Promise | void; + onStatus?: (status: DesktopIdentityStatus) => void; + timeoutMs?: number; +} + +export function isDesktopSignInNavigation( + navigationUrl: string, + app: Pick, +): boolean { + try { + const parsed = new URL(navigationUrl); + return ( + parsed.origin === app.origin && parsed.pathname === DESKTOP_SIGN_IN_PATH + ); + } catch { + return false; + } +} + +function completionUrl(origin: string, nonce: string): string { + const result = new URL(DESKTOP_IDENTITY_COMPLETE_PATH, origin); + result.searchParams.set("nonce", nonce); + return result.toString(); +} + +export function isDesktopIdentityCompletion( + navigationUrl: string, + app: Pick, + nonce: string, +): boolean { + try { + const parsed = new URL(navigationUrl); + return ( + parsed.origin === app.origin && + parsed.pathname === DESKTOP_IDENTITY_COMPLETE_PATH && + parsed.searchParams.get("nonce") === nonce + ); + } catch { + return false; + } +} + +export class DesktopIdentityBroker { + private readonly pendingByApp = new Map>(); + private readonly unsupportedAppIds = new Set(); + private queue: Promise = Promise.resolve(); + private activeWindow: DesktopIdentityWindow | null = null; + private status: DesktopIdentityStatus = "idle"; + + constructor(private readonly options: DesktopIdentityBrokerOptions) {} + + getStatus(): DesktopIdentityStatus { + return this.status; + } + + async refreshStatus(authorityApp: DesktopIdentityApp | null): Promise { + if (!authorityApp) { + this.setStatus("idle"); + return; + } + const cookies = await this.options.identitySession.cookies.get({ + url: authorityApp.origin, + }); + const allowed = new Set(authorityApp.cookieNames); + this.setStatus( + cookies.some((cookie) => allowed.has(cookie.name)) + ? "signed-in" + : "sign-in-required", + ); + } + + handleSignedOutNavigation(appId: string, navigationUrl: string): boolean { + const app = this.options.resolveApp(appId); + if ( + !app || + this.unsupportedAppIds.has(appId) || + !isDesktopSignInNavigation(navigationUrl, app) + ) { + return false; + } + void this.ensureAppSession(appId); + return true; + } + + ensureAppSession(appId: string): Promise { + const existing = this.pendingByApp.get(appId); + if (existing) return existing; + + const operation = this.queue.then(() => this.runCeremony(appId)); + this.queue = operation.then( + () => undefined, + () => undefined, + ); + this.pendingByApp.set(appId, operation); + void operation.finally(() => { + if (this.pendingByApp.get(appId) === operation) { + this.pendingByApp.delete(appId); + } + }); + return operation; + } + + async signOut(apps: DesktopIdentityApp[]): Promise { + this.closeActiveWindow(); + await this.options.identitySession.clearStorageData({ + storages: ["cookies"], + }); + + for (const app of apps) { + for (const cookieName of app.cookieNamesToClear) { + await app.session.cookies + .remove(app.origin, cookieName) + .catch(() => {}); + } + this.options.reloadApp(app); + } + await this.options.clearLocalBroker(); + this.setStatus("sign-in-required"); + } + + private async runCeremony(appId: string): Promise { + const app = this.options.resolveApp(appId); + if (!app) return false; + + this.setStatus("signing-in"); + const nonce = randomBytes(32).toString("base64url"); + const returnPath = new URL(completionUrl(app.origin, nonce)); + const loginUrl = new URL(DESKTOP_IDENTITY_LOGIN_PATH, app.origin); + loginUrl.searchParams.set( + "return", + returnPath.pathname + returnPath.search, + ); + + let initialUrl = loginUrl.toString(); + if (typeof this.options.identitySession.fetch === "function") { + try { + const response = await this.options.identitySession.fetch(initialUrl, { + redirect: "manual", + }); + const location = response.headers.get("location"); + if (response.status < 300 || response.status >= 400 || !location) { + this.unsupportedAppIds.add(app.id); + this.setStatus("failed"); + this.options.reloadApp(app); + return false; + } + initialUrl = new URL(location, initialUrl).toString(); + } catch { + this.unsupportedAppIds.add(app.id); + this.setStatus("failed"); + this.options.reloadApp(app); + return false; + } + } + + const identityWindow = this.options.createWindow({ + width: 520, + height: 720, + title: "Sign in to Agent Native", + backgroundColor: "#111111", + parent: this.options.parentWindow?.() ?? undefined, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + session: this.options.identitySession, + }, + }); + this.activeWindow = identityWindow; + + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (ok: boolean, status: DesktopIdentityStatus) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + if (this.activeWindow === identityWindow) this.activeWindow = null; + this.setStatus(status); + if (!identityWindow.isDestroyed()) identityWindow.close(); + resolve(ok); + }; + + const inspectNavigation = (event: Electron.Event, url: string) => { + if (isDesktopIdentityCompletion(url, app, nonce)) { + event.preventDefault(); + void this.copyTargetSession(app).then( + () => { + this.options.reloadApp(app); + finish(true, "signed-in"); + }, + () => finish(false, "failed"), + ); + return; + } + if ( + this.options.handleOAuthNavigation?.(url, identityWindow.webContents) + ) { + event.preventDefault(); + } + }; + + identityWindow.webContents.on("will-navigate", inspectNavigation); + identityWindow.webContents.on("will-redirect", (event, url) => + inspectNavigation(event, url), + ); + identityWindow.webContents.setWindowOpenHandler(({ url }) => + this.options.handleWindowOpen + ? this.options.handleWindowOpen(identityWindow.webContents, url) + : { action: "deny" }, + ); + identityWindow.webContents.on("render-process-gone", () => + finish(false, "failed"), + ); + identityWindow.on("closed", () => finish(false, "sign-in-required")); + + timer = setTimeout( + () => finish(false, "sign-in-required"), + this.options.timeoutMs ?? DEFAULT_CEREMONY_TIMEOUT_MS, + ); + + void identityWindow.loadURL(initialUrl).catch(() => { + finish(false, "failed"); + }); + }); + } + + private async copyTargetSession(app: DesktopIdentityApp): Promise { + const sourceCookies = await this.options.identitySession.cookies.get({ + url: app.origin, + }); + const allowed = new Set(app.cookieNames); + const cookies = sourceCookies.filter((cookie) => allowed.has(cookie.name)); + if (cookies.length === 0) throw new Error("Missing app session cookie"); + + for (const cookieName of app.cookieNames) { + await app.session.cookies.remove(app.origin, cookieName).catch(() => {}); + } + for (const cookie of cookies) { + await app.session.cookies.set({ + url: app.origin, + name: cookie.name, + value: cookie.value, + path: cookie.path || "/", + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + ...(cookie.expirationDate + ? { expirationDate: cookie.expirationDate } + : {}), + }); + } + + if (!app.identityAuthority) { + for (const cookie of cookies) { + await this.options.identitySession.cookies + .remove(app.origin, cookie.name) + .catch(() => {}); + } + } + } + + private closeActiveWindow(): void { + const active = this.activeWindow; + this.activeWindow = null; + if (active && !active.isDestroyed()) active.close(); + } + + private setStatus(status: DesktopIdentityStatus): void { + this.status = status; + this.options.onStatus?.(status); + } +} diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 8d1b3b6543..dd4b1eb9df 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -13,6 +13,7 @@ import path from "path"; import { fileURLToPath } from "url"; import { + DESKTOP_DEFAULT_APPS, FRAME_PORT, getDesktopTemplateGatewayAppUrl, getTemplate, @@ -100,6 +101,7 @@ import { type DesktopPlanFilesResult, type DesktopPlanFilesWriteRequest, type DesktopPlanMdxFolder, + type DesktopIdentityStatus, } from "@shared/ipc-channels"; import { app, @@ -164,6 +166,12 @@ import { SwiftDesktopHelperClient, } from "./computer-control"; import { DesktopDesignPreviewManager } from "./design-preview-manager"; +import { + DESKTOP_IDENTITY_PARTITION, + DesktopIdentityBroker, + isDesktopWorkspaceLogoutRequest, + type DesktopIdentityApp, +} from "./desktop-identity"; import { captureWebviewLogs, initializeDesktopLogger, @@ -265,6 +273,8 @@ let mainWindow: BrowserWindow | null = null; let desktopDesignPreviewManager: DesktopDesignPreviewManager | null = null; let desktopComputerMcpBridge: DesktopComputerMcpBridge | null = null; let desktopBrowserControlBridge: BrowserControlLoopbackBridge | null = null; +let desktopIdentityBroker: DesktopIdentityBroker | null = null; +const desktopWebviewAppIds = new WeakMap(); let browserNativeHostManifestPath: string | null = null; const pendingOpenRequests: DesktopOpenRequest[] = []; const PENDING_OAUTH_STATE_TTL_MS = 10 * 60 * 1000; @@ -542,6 +552,56 @@ function getInjectionTargetForAppId( }; } +function resolveDesktopIdentityApp(appId: string): DesktopIdentityApp | null { + if (!app.isPackaged) return null; + const canonical = DESKTOP_DEFAULT_APPS.find( + (candidate) => candidate.id === appId, + ); + const configured = loadAppsForAuthContext().find( + (candidate) => candidate.id === appId && candidate.enabled !== false, + ); + if (!canonical || !configured || configured.mode === "dev") return null; + + const canonicalOrigin = getAppOrigin({ ...canonical, mode: "prod" }); + const configuredOrigin = getAppOrigin(configured); + if ( + !canonicalOrigin || + !configuredOrigin || + canonicalOrigin !== configuredOrigin || + !canonicalOrigin.startsWith("https://") + ) { + return null; + } + + const primaryCookieName = getCookieNameForApp(appId); + const appSlug = primaryCookieName.replace(/^an_session_/, ""); + const betterAuthPrefix = appSlug ? `an_${appSlug}` : "an"; + return { + id: appId, + origin: canonicalOrigin, + session: session.fromPartition(`persist:app-${appId}`), + cookieNames: + primaryCookieName === "an_session" + ? [primaryCookieName] + : [primaryCookieName, "an_session"], + cookieNamesToClear: [ + primaryCookieName, + "an_session", + `${betterAuthPrefix}.session_token`, + `__Secure-${betterAuthPrefix}.session_token`, + "an.session_token", + "__Secure-an.session_token", + ], + identityAuthority: appId === "dispatch", + }; +} + +function listDesktopIdentityApps(): DesktopIdentityApp[] { + return DESKTOP_DEFAULT_APPS.map((candidate) => + resolveDesktopIdentityApp(candidate.id), + ).filter((candidate): candidate is DesktopIdentityApp => candidate !== null); +} + function getOAuthInjectionTarget( sourceSession: Electron.Session | undefined, sourceUrl: string | undefined, @@ -966,6 +1026,31 @@ app.on("browser-window-focus", () => { // (imported above) are also used by the application menu below. registerUpdatesIpc({ refreshApplicationMenu, focusMainWindow }); +function isShellIdentityIpc(event: IpcMainInvokeEvent): boolean { + return Boolean( + mainWindow && + !mainWindow.isDestroyed() && + event.sender.id === mainWindow.webContents.id, + ); +} + +ipcMain.handle(IPC.IDENTITY_STATUS_GET, (event) => { + if (!isShellIdentityIpc(event)) + return "failed" satisfies DesktopIdentityStatus; + return desktopIdentityBroker?.getStatus() ?? "idle"; +}); + +ipcMain.handle(IPC.IDENTITY_SIGN_IN, async (event) => { + if (!isShellIdentityIpc(event) || !desktopIdentityBroker) return false; + return desktopIdentityBroker.ensureAppSession("dispatch"); +}); + +ipcMain.handle(IPC.IDENTITY_SIGN_OUT, async (event) => { + if (!isShellIdentityIpc(event) || !desktopIdentityBroker) return false; + await desktopIdentityBroker.signOut(listDesktopIdentityApps()); + return true; +}); + function createWindow(): BrowserWindow { if (mainWindow && !mainWindow.isDestroyed()) { return mainWindow; @@ -8737,6 +8822,15 @@ function installWebviewOAuthNavigationHandler(contents: Electron.WebContents) { url: string, options: { isMainFrame: boolean }, ) => { + const desktopAppId = desktopWebviewAppIds.get(contents); + if ( + options.isMainFrame && + desktopAppId && + desktopIdentityBroker?.handleSignedOutNavigation(desktopAppId, url) + ) { + event.preventDefault(); + return; + } if (handleDesktopProtocolUrl(url)) { event.preventDefault(); return; @@ -8770,6 +8864,9 @@ function installWebviewOAuthNavigationHandler(contents: Electron.WebContents) { contents.on("will-navigate", (event) => { handleNavigation(event, event.url, { isMainFrame: true }); }); + contents.on("will-redirect", (event, url, _isInPlace, isMainFrame) => { + handleNavigation(event, url, { isMainFrame }); + }); } // ---------- Webview popup handling ---------- @@ -8967,6 +9064,12 @@ function installApplicationMenu() { buildUpdateMenuItem(), buildCurrentVersionMenuItem(), { type: "separator" as const }, + { + label: "Sign Out of Agent Native", + click: () => + void desktopIdentityBroker?.signOut(listDesktopIdentityApps()), + }, + { type: "separator" as const }, { role: "services" as const }, { type: "separator" as const }, { role: "hide" as const }, @@ -9103,6 +9206,37 @@ function configurePermissionHandlers( } app.whenReady().then(async () => { + desktopIdentityBroker = new DesktopIdentityBroker({ + identitySession: session.fromPartition(DESKTOP_IDENTITY_PARTITION), + resolveApp: resolveDesktopIdentityApp, + createWindow: (options) => new BrowserWindow(options), + parentWindow: () => mainWindow, + handleWindowOpen: (contents, url) => + handleWindowOpenForContents(contents, url), + handleOAuthNavigation: (url, contents) => + openOAuthFromWebviewNavigation(url, contents), + reloadApp: (identityApp) => + reloadWebviewsForTarget({ + appId: identityApp.id, + origin: identityApp.origin, + session: identityApp.session, + }), + clearLocalBroker: async () => { + await fs.promises + .rm(path.join(os.homedir(), ".agent-native", "desktop-sso.json"), { + force: true, + }) + .catch(() => {}); + }, + onStatus: (status: DesktopIdentityStatus) => { + if (!mainWindow || mainWindow.isDestroyed()) return; + mainWindow.webContents.send(IPC.IDENTITY_STATUS_CHANGED, status); + }, + }); + await desktopIdentityBroker.refreshStatus( + resolveDesktopIdentityApp("dispatch"), + ); + await initializeDesktopComputerMcpBridge(); // Process any deep link that arrived before the app was ready if (pendingDeepLink) { @@ -9141,8 +9275,33 @@ app.whenReady().then(async () => { // Each partition is bound to a specific app, so route to that app's port // rather than falling back to a hardcoded mail/calendar preference. sess.webRequest.onBeforeRequest( - { urls: [`http://localhost:${FRAME_PORT}/api/google/*`] }, + { + urls: [ + `http://localhost:${FRAME_PORT}/api/google/*`, + "*://*/_agent-native/auth/logout", + "*://*/_agent-native/auth/logout-all", + ], + }, (details, callback) => { + const identityApp = targetAppId + ? resolveDesktopIdentityApp(targetAppId) + : null; + if ( + identityApp && + details.method === "POST" && + isDesktopWorkspaceLogoutRequest(details.url, identityApp) + ) { + callback({ cancel: true }); + void desktopIdentityBroker?.signOut(listDesktopIdentityApps()); + return; + } + + if ( + !details.url.startsWith(`http://localhost:${FRAME_PORT}/api/google/`) + ) { + callback({}); + return; + } let apps: AppConfig[] = []; try { apps = AppStore.loadApps(); @@ -9200,6 +9359,7 @@ app.whenReady().then(async () => { id = new URL(wc.getURL()).searchParams.get("app"); } catch {} } + if (id) desktopWebviewAppIds.set(wc, id); configureWebviewSession(wc.session, id); // Capture renderer console messages to the log file so they survive // across sessions without DevTools needing to be open. diff --git a/packages/desktop-app/src/preload/index.spec.ts b/packages/desktop-app/src/preload/index.spec.ts index 9f8a36887c..a0568c6be8 100644 --- a/packages/desktop-app/src/preload/index.spec.ts +++ b/packages/desktop-app/src/preload/index.spec.ts @@ -1,5 +1,6 @@ import { beforeAll, describe, expect, it, vi } from "vitest"; +import { IPC } from "../../shared/ipc-channels.js"; import { MULTI_FRONTIER_CHANNELS } from "../../shared/multi-frontier-channels.js"; const electron = vi.hoisted(() => { @@ -149,6 +150,42 @@ describe("multi-frontier preload API", () => { }); }); +describe("workspace identity preload API", () => { + it("exposes only bounded commands and status", async () => { + const api = ( + electron.exposed as { + identity: { + getStatus(): Promise; + signIn(): Promise; + signOut(): Promise; + onStatusChange(callback: (status: string) => void): () => void; + }; + } + ).identity; + + await api.getStatus(); + await api.signIn(); + await api.signOut(); + expect(electron.invoke).toHaveBeenCalledWith(IPC.IDENTITY_STATUS_GET); + expect(electron.invoke).toHaveBeenCalledWith(IPC.IDENTITY_SIGN_IN); + expect(electron.invoke).toHaveBeenCalledWith(IPC.IDENTITY_SIGN_OUT); + expect(api).not.toHaveProperty("cookie"); + expect(api).not.toHaveProperty("token"); + expect(api).not.toHaveProperty("email"); + + const callback = vi.fn(); + const unsubscribe = api.onStatusChange(callback); + const listener = electron.listeners.get(IPC.IDENTITY_STATUS_CHANGED)!; + listener({}, "signed-in"); + expect(callback).toHaveBeenCalledWith("signed-in"); + unsubscribe(); + expect(electron.removeListener).toHaveBeenCalledWith( + IPC.IDENTITY_STATUS_CHANGED, + listener, + ); + }); +}); + function exposedMultiFrontierApi() { return ( electron.exposed as { diff --git a/packages/desktop-app/src/preload/index.ts b/packages/desktop-app/src/preload/index.ts index 5577cb5dd0..0d7f1b677a 100644 --- a/packages/desktop-app/src/preload/index.ts +++ b/packages/desktop-app/src/preload/index.ts @@ -39,6 +39,7 @@ import { type DesktopAppContextAction, type DesktopAppCreationSettings, type DesktopAppRuntimeStatus, + type DesktopIdentityStatus, type DesktopCreateAppRequest, type DesktopCreateAppResult, type DesktopShortcutActivationRequest, @@ -205,6 +206,25 @@ const electronAPI = { }, }, + /** Workspace identity commands expose intent and status, never credentials. */ + identity: { + getStatus: (): Promise => + ipcRenderer.invoke(IPC.IDENTITY_STATUS_GET), + signIn: (): Promise => ipcRenderer.invoke(IPC.IDENTITY_SIGN_IN), + signOut: (): Promise => ipcRenderer.invoke(IPC.IDENTITY_SIGN_OUT), + onStatusChange: ( + cb: (status: DesktopIdentityStatus) => void, + ): (() => void) => { + const handler = ( + _: Electron.IpcRendererEvent, + status: DesktopIdentityStatus, + ) => cb(status); + ipcRenderer.on(IPC.IDENTITY_STATUS_CHANGED, handler); + return () => + ipcRenderer.removeListener(IPC.IDENTITY_STATUS_CHANGED, handler); + }, + }, + /** Tell main process which app webview is currently active (for DevTools targeting) */ setActiveApp: (appId: string) => ipcRenderer.send(IPC.SET_ACTIVE_APP, appId), setActiveWebview: (target: ActiveWebviewTarget) => diff --git a/packages/desktop-app/src/renderer/components/AppSettings.tsx b/packages/desktop-app/src/renderer/components/AppSettings.tsx index b12a77758b..c6bc9dd034 100644 --- a/packages/desktop-app/src/renderer/components/AppSettings.tsx +++ b/packages/desktop-app/src/renderer/components/AppSettings.tsx @@ -13,7 +13,7 @@ import { type DesktopShortcutSettings, type DesktopShortcutUpsertRequest, } from "@shared/desktop-shortcuts"; -import type { UpdateStatus } from "@shared/ipc-channels"; +import type { DesktopIdentityStatus, UpdateStatus } from "@shared/ipc-channels"; import { IconX, IconPlus, @@ -32,6 +32,9 @@ import { IconFolderPlus, IconAlertCircle, IconKeyboard, + IconLogin2, + IconLogout2, + IconUserCircle, } from "@tabler/icons-react"; import { useState, @@ -518,6 +521,8 @@ export default function AppSettings({ ); const [shortcutMessage, setShortcutMessage] = useState(null); const [shortcutSaving, setShortcutSaving] = useState(false); + const [identityStatus, setIdentityStatus] = + useState("idle"); const [isClosing, setIsClosing] = useState(false); const closingTimerRef = useRef(null); const shortcutTargetApps = useMemo( @@ -559,6 +564,27 @@ export default function AppSettings({ } }, [onFrameSettingsChanged]); + useEffect(() => { + const api = window.electronAPI?.identity; + if (!api) return; + void api.getStatus().then(setIdentityStatus); + return api.onStatusChange(setIdentityStatus); + }, []); + + const handleIdentitySignIn = useCallback(async () => { + const api = window.electronAPI?.identity; + if (!api) return; + await api.signIn(); + setIdentityStatus(await api.getStatus()); + }, []); + + const handleIdentitySignOut = useCallback(async () => { + const api = window.electronAPI?.identity; + if (!api) return; + await api.signOut(); + setIdentityStatus(await api.getStatus()); + }, []); + const refreshProviderSettings = useCallback(async () => { const api = window.electronAPI?.codeAgents; if (!api?.getProviderSettings) return; @@ -874,6 +900,51 @@ export default function AppSettings({ +
+
+ +
+ + Agent Native account + + + {identityStatus === "signed-in" + ? "Signed in once for first-party apps" + : identityStatus === "signing-in" + ? "Signing in to your workspace…" + : identityStatus === "failed" + ? "Sign-in failed. Try again." + : "Sign in once, then open every first-party app"} + +
+
+
+ {identityStatus === "signed-in" ? ( + + ) : ( + + )} +
+
+ {providerSettings && ( ; + signIn(): Promise; + signOut(): Promise; + onStatusChange(cb: (status: DesktopIdentityStatus) => void): () => void; + }; + setActiveApp(appId: string): void; setActiveWebview(target: { appId: string; diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md new file mode 100644 index 0000000000..d6bc7b8e0c --- /dev/null +++ b/plans/desktop-sso-intent/work-ledger.md @@ -0,0 +1,95 @@ +# Desktop workspace SSO work ledger + +```yaml +stage: work +authority-source: "Alice: Sweet. Work it." +authorized-scope: + repositories: + - BuilderIO/agent-native + product-surfaces: + - Agent Native Desktop packaged application + - Core authentication and cross-app identity federation + - Dispatch identity authority + - first-party hosted Agent Native app fleet + outcome: one human sign-in for all canonical first-party apps opened in Agent Native Desktop +allowed-mutations: + - artifact-write + - branch + - commit +write-targets: + artifacts: + - packages/desktop-app + - packages/core + - docs + - .agents/skills/authentication + - .changeset + - plans/desktop-sso-intent/work-ledger.md +governing-artifact: + path: /Users/alicemoore/.codex/worktrees/52a5/agent-native/plans/desktop-sso-intent/implementation-brief.md + revision: desktop-sso-brief-r1 +architecture-fingerprint: + outcome: one Desktop workspace identity ceremony followed by silent app-local federation + shipping-surfaces: + - id: desktop-shell + repository: BuilderIO/agent-native + product-surface: signed packaged Agent Native Desktop app + constituency: users of canonical first-party Agent Native apps + durable-destination: Agent Native Desktop release + - id: core-identity + repository: BuilderIO/agent-native + product-surface: public Core authentication and identity-federation runtime + constituency: source-blind Agent Native developers and app users + durable-destination: published @agent-native/core package + - id: dispatch-authority + repository: BuilderIO/agent-native + product-surface: Dispatch identity authorize flow + constituency: first-party workspace users and self-hosted workspace operators + durable-destination: Dispatch template and deployment + - id: hosted-app-fleet + repository: BuilderIO/agent-native + product-surface: canonical first-party hosted app deployments + constituency: Agent Native Desktop users + durable-destination: per-app production deployments and auth configuration + governing-architecture: Electron main owns one dedicated Dispatch identity partition and uses existing app-local federation; local development retains its loopback file broker; custom apps and Builder Connect remain outside the boundary + acceptance-story: + id: desktop-first-party-sso-v1 + summary: one Desktop sign-in opens every canonical first-party app as the same verified account while preserving app-local sessions, databases, authorization, and data isolation + required-assertions: + - signed packaged Desktop and real canary deployment pass before merge + - every enabled canonical first-party app resolves the correct existing account and data without another credential ceremony + - restart, workspace sign-out, account switch, standalone browser, custom app, and local-development behavior pass + - hostile redirect, nonce, origin, cookie, concurrency, cancellation, and logging cases fail closed + - Builder internal and Builder credentials do not participate + risk-strategy: + kind: system-ready + production-validation-after-merge: false +delegation-ceiling: + - read-only inventory and verification +product-boundary-gates: + agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure +acceptance-state: + status: pending + summary: implementation and automated verification are complete; live canary and signed installed-app acceptance remain outstanding + verified: + - Core identity protocol suite: 210 tests passed + - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed + - Desktop TypeScript clean-machine typecheck passed on Framework + - Core package build passed on Framework + - Desktop production compile passed on macOS + - i18n catalog guard and git diff checks passed + implementation: + - authenticated nonce-only app-local completion route in Core + - dedicated persistent Dispatch identity partition in packaged Desktop + - canonical-registry-only app session federation with target-cookie filtering + - serialized and coalesced sign-in ceremonies with direct-login fallback + - renderer-safe status and sign-in/sign-out IPC without credential material + - workspace-wide Desktop sign-out for exact canonical POST logout requests + - operator docs, all localized counterparts, authentication skill, and Core changeset + blockers: + - no connected deployment project was available to deploy or inspect a real canary + - this Mac has no valid code-signing identity, so a signed package cannot be produced here + - the signed installed-app matrix still needs restart, account switch, standalone browser, custom app, local-development, and hostile-flow verification against the canary + last-land-packet: null +ledger-revision: desktop-sso-work-r3 +status: active +``` From 32e425f13e1d49d96020f4dae51fce66900dfca3 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:49:29 -0400 Subject: [PATCH 02/31] Prepare Desktop SSO canary deployment --- .changeset/calm-desktops-federate.md | 3 + .github/workflows/desktop-canary.yml | 109 ++++++++++++++++++ packages/dispatch/src/config.ts | 2 + .../dispatch/src/server/plugins/auth.spec.ts | 45 ++++++++ packages/dispatch/src/server/plugins/auth.ts | 1 + plans/desktop-sso-intent/work-ledger.md | 30 ++++- .../dispatch/server/plugins/identity-sso.ts | 32 ++--- .../server/plugins/org-apps-directory.ts | 16 +-- .../dispatch/server/plugins/setup-dispatch.ts | 9 +- 9 files changed, 206 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/desktop-canary.yml create mode 100644 packages/dispatch/src/server/plugins/auth.spec.ts diff --git a/.changeset/calm-desktops-federate.md b/.changeset/calm-desktops-federate.md index 2d1c6bb2b6..43086d03c8 100644 --- a/.changeset/calm-desktops-federate.md +++ b/.changeset/calm-desktops-federate.md @@ -1,5 +1,8 @@ --- "@agent-native/core": minor +"@agent-native/dispatch": patch --- Add an authenticated, nonce-only completion route for packaged Desktop clients orchestrating cross-app identity federation. + +Ensure Dispatch installs identity federation routes on its primary auth guard so concurrent Nitro plugin startup cannot pre-empt them with a 401. diff --git a/.github/workflows/desktop-canary.yml b/.github/workflows/desktop-canary.yml new file mode 100644 index 0000000000..d71021c3be --- /dev/null +++ b/.github/workflows/desktop-canary.yml @@ -0,0 +1,109 @@ +name: Desktop SSO Canary + +on: + push: + branches: + - codex/desktop-workspace-sso + +permissions: + contents: read + +jobs: + build-signed-macos-canary: + if: github.ref == 'refs/heads/codex/desktop-workspace-sso' + runs-on: macos-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + + - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 + + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Set canary version + working-directory: packages/desktop-app + env: + CANARY_SUFFIX: ${{ github.run_number }} + run: | + BASE=$(node -p "require('./package.json').version") + npm version "${BASE}-desktop-sso-canary.${CANARY_SUFFIX}" --no-git-tag-version + + - name: Import signing certificate + env: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + set -euo pipefail + CERTIFICATE_PATH="$RUNNER_TEMP/desktop-canary-certificate.p12" + KEYCHAIN_PATH="$RUNNER_TEMP/desktop-canary.keychain-db" + echo "$APPLE_CERTIFICATE" | base64 --decode > "$CERTIFICATE_PATH" + security create-keychain -p "" "$KEYCHAIN_PATH" + security default-keychain -s "$KEYCHAIN_PATH" + security unlock-keychain -p "" "$KEYCHAIN_PATH" + security import "$CERTIFICATE_PATH" -k "$KEYCHAIN_PATH" -P "$APPLE_CERTIFICATE_PASSWORD" -T /usr/bin/codesign + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" "$KEYCHAIN_PATH" + rm "$CERTIFICATE_PATH" + + - name: Build signed and notarized canary + working-directory: packages/desktop-app + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: W3PMF2T3MW + SENTRY_DESKTOP_DSN: ${{ secrets.SENTRY_DESKTOP_DSN || secrets.SENTRY_ELECTRON_DSN || secrets.SENTRY_CLIENT_DSN || secrets.SENTRY_DSN }} + SENTRY_DESKTOP_CLIENT_KEY: ${{ secrets.SENTRY_DESKTOP_CLIENT_KEY || secrets.SENTRY_CLIENT_KEY }} + SENTRY_DESKTOP_PROJECT_ID: ${{ secrets.SENTRY_DESKTOP_PROJECT_ID || secrets.SENTRY_PROJECT_ID }} + SENTRY_DESKTOP_INGEST_HOST: ${{ secrets.SENTRY_DESKTOP_INGEST_HOST || secrets.SENTRY_INGEST_HOST }} + SENTRY_DESKTOP_ENVIRONMENT: canary + run: pnpm build && npx electron-builder --mac --config --publish never + + - name: Verify candidate provenance and trust + working-directory: packages/desktop-app + env: + EXPECTED_COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$EXPECTED_COMMIT" + VERSION=$(node -p "require('./package.json').version") + test -n "$VERSION" + + APP_COUNT=0 + while IFS= read -r -d '' APP_PATH; do + APP_COUNT=$((APP_COUNT + 1)) + INFO="$APP_PATH/Contents/Info.plist" + codesign --verify --deep --strict --verbose=2 "$APP_PATH" + xcrun stapler validate "$APP_PATH" + spctl --assess --type execute --verbose=4 "$APP_PATH" + /usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" "$INFO" | grep -qx 'com.agentnative.desktop' + /usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$INFO" | grep -Fqx "$VERSION" + done < <(find dist -type d -name 'Agent Native.app' -print0) + test "$APP_COUNT" -eq 2 + + { + echo "workflow_run=${GITHUB_RUN_ID}" + echo "commit=${EXPECTED_COMMIT}" + echo "version=${VERSION}" + echo "bundle_id=com.agentnative.desktop" + } > dist/CANARY-MANIFEST.txt + find dist -maxdepth 1 -type f \( -name '*.dmg' -o -name '*.zip' \) -print0 \ + | sort -z \ + | xargs -0 shasum -a 256 >> dist/CANARY-MANIFEST.txt + grep -q '\.dmg$' < <(find dist -maxdepth 1 -type f -name '*.dmg') + grep -q '\.zip$' < <(find dist -maxdepth 1 -type f -name '*.zip') + + - name: Upload short-lived canary artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: desktop-sso-canary-${{ github.sha }} + path: | + packages/desktop-app/dist/*.dmg + packages/desktop-app/dist/*.zip + packages/desktop-app/dist/CANARY-MANIFEST.txt + if-no-files-found: error + retention-days: 3 diff --git a/packages/dispatch/src/config.ts b/packages/dispatch/src/config.ts index ae081cc9e6..524cbeb023 100644 --- a/packages/dispatch/src/config.ts +++ b/packages/dispatch/src/config.ts @@ -13,6 +13,8 @@ export interface DispatchAuthConfig { googleOnly?: boolean; /** Marketing/branding copy passed straight through to `createAuthPlugin`. */ marketing?: Record; + /** Routes that perform their own authentication before the primary guard. */ + publicPaths?: string[]; } export interface DispatchIntegrationsConfig { diff --git a/packages/dispatch/src/server/plugins/auth.spec.ts b/packages/dispatch/src/server/plugins/auth.spec.ts new file mode 100644 index 0000000000..aee243f895 --- /dev/null +++ b/packages/dispatch/src/server/plugins/auth.spec.ts @@ -0,0 +1,45 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + authPlugin: vi.fn(), + createAuthPlugin: vi.fn(), + getDispatchConfig: vi.fn(), +})); + +vi.mock("@agent-native/core/server", () => ({ + createAuthPlugin: mocks.createAuthPlugin, +})); + +vi.mock("../index.js", () => ({ + getDispatchConfig: mocks.getDispatchConfig, +})); + +describe("dispatchAuthPlugin", () => { + beforeEach(() => { + vi.resetModules(); + mocks.authPlugin.mockReset(); + mocks.createAuthPlugin.mockReset(); + mocks.createAuthPlugin.mockReturnValue(mocks.authPlugin); + mocks.getDispatchConfig.mockReset(); + }); + + it("installs template public routes on the primary auth guard", async () => { + const { default: dispatchAuthPlugin } = await import("./auth.js"); + const nitroApp = {}; + const publicPaths = [ + "/_agent-native/identity/authorize", + "/_agent-native/org/apps", + ]; + + mocks.getDispatchConfig.mockReturnValue({ + auth: { googleOnly: true, publicPaths }, + }); + await dispatchAuthPlugin(nitroApp); + + expect(mocks.createAuthPlugin).toHaveBeenCalledOnce(); + expect(mocks.createAuthPlugin).toHaveBeenCalledWith( + expect.objectContaining({ googleOnly: true, publicPaths }), + ); + expect(mocks.authPlugin).toHaveBeenCalledWith(nitroApp); + }); +}); diff --git a/packages/dispatch/src/server/plugins/auth.ts b/packages/dispatch/src/server/plugins/auth.ts index 00aaf27a0f..7ae7de43a5 100644 --- a/packages/dispatch/src/server/plugins/auth.ts +++ b/packages/dispatch/src/server/plugins/auth.ts @@ -28,6 +28,7 @@ const dispatchAuthPlugin = async (nitroApp: any) => { const plugin = createAuthPlugin({ googleOnly, marketing: marketing as any, + publicPaths: authConfig.publicPaths, }); return plugin(nitroApp); }; diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index d6bc7b8e0c..e2b92a71c8 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -2,10 +2,11 @@ ```yaml stage: work -authority-source: "Alice: Sweet. Work it." +authority-source: "Alice: All right. Log that plan to the vault and work it." authorized-scope: repositories: - BuilderIO/agent-native + - alicemoore/teenylilthoughts product-surfaces: - Agent Native Desktop packaged application - Core authentication and cross-app identity federation @@ -16,6 +17,9 @@ allowed-mutations: - artifact-write - branch - commit + - push + - pull-request + - deploy write-targets: artifacts: - packages/desktop-app @@ -23,7 +27,9 @@ write-targets: - docs - .agents/skills/authentication - .changeset + - .github/workflows/desktop-canary.yml - plans/desktop-sso-intent/work-ledger.md + - /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md governing-artifact: path: /Users/alicemoore/.codex/worktrees/52a5/agent-native/plans/desktop-sso-intent/implementation-brief.md revision: desktop-sso-brief-r1 @@ -77,6 +83,9 @@ acceptance-state: - Core package build passed on Framework - Desktop production compile passed on macOS - i18n catalog guard and git diff checks passed + - Dispatch package suite: 269 tests passed; package build and typecheck passed on Framework + - Dispatch template suite: 40 tests passed; template typecheck passed on Framework + - production-shaped Netlify bundle changed the valid logged-out identity authorize request from 401 to the expected 302 sign-in redirect implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -85,11 +94,24 @@ acceptance-state: - renderer-safe status and sign-in/sign-out IPC without credential material - workspace-wide Desktop sign-out for exact canonical POST logout requests - operator docs, all localized counterparts, authentication skill, and Core changeset + - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed + - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption blockers: - - no connected deployment project was available to deploy or inspect a real canary - - this Mac has no valid code-signing identity, so a signed package cannot be produced here + - signed packaging depends on the repository's GitHub-hosted Apple signing and notarization secrets + - production promotion requires green CI, a successful signed candidate, human review, and freshly captured rollback evidence - the signed installed-app matrix still needs restart, account switch, standalone browser, custom app, local-development, and hostile-flow verification against the canary last-land-packet: null -ledger-revision: desktop-sso-work-r3 +deployment-boundary: + allowed: + - branch-scoped GitHub Actions macOS canary build with publish disabled + - immutable Dispatch and Mail candidate deploy preparation + - short, announced Mail canonical-origin production canary with exact rollback target + forbidden: + - editing the stable desktop release workflow + - tags, updater feeds, or GitHub releases + - merge or stable Desktop publication without a separate decision + - enabling arbitrary preview hosts, custom apps, or Builder credentials +vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md +ledger-revision: desktop-sso-work-r5 status: active ``` diff --git a/templates/dispatch/server/plugins/identity-sso.ts b/templates/dispatch/server/plugins/identity-sso.ts index f4b07db91e..cbe9448ec4 100644 --- a/templates/dispatch/server/plugins/identity-sso.ts +++ b/templates/dispatch/server/plugins/identity-sso.ts @@ -51,28 +51,16 @@ * * Auth-guard reachability: * `/_agent-native/*` is 401'd by the core auth guard when there is no - * session, which would break the logged-OUT bounce. So this plugin - * registers the exact path `/_agent-native/identity/authorize` as a - * `publicPath` via a second `createAuthPlugin({ publicPaths })` call. - * When two `createAuthPlugin` calls run in the same server boot on the - * same Nitro app, the framework APPENDS publicPaths to the live guard - * config (it does not clobber Dispatch's googleOnly/marketing/onboarding - * — verified in packages/core/src/server/auth.ts). The path is matched - * exactly (or as a `/`-segment prefix), so ONLY the authorize endpoint - * becomes public; any future `/_agent-native/identity/*` route stays - * protected. The handler then resolves the session ITSELF (exactly the - * `/_agent-native/open` pattern) — public-path only means "guard does not - * pre-empt", not "no auth": logged-out users are still bounced to login, - * and a token is only minted for a real session. + * session, which would break the logged-OUT bounce. Dispatch's primary + * auth plugin therefore receives this exact path through `setupDispatch`. + * A second auth initializer is unsafe because Nitro starts plugin + * initializers concurrently. The handler still resolves the session + * itself: public-path only means "guard does not pre-empt", not "no auth". */ import { signA2AToken } from "@agent-native/core/a2a"; import { getOrgDomain } from "@agent-native/core/org"; -import { - createAuthPlugin, - getH3App, - getSession, -} from "@agent-native/core/server"; +import { getH3App, getSession } from "@agent-native/core/server"; import { defineEventHandler, getMethod } from "h3"; import type { H3Event } from "h3"; @@ -227,13 +215,9 @@ const authorizeHandler = defineEventHandler( ); /** - * Dispatch identity-SSO plugin. Mounts the authorize route and registers - * its exact path as a public path so the core auth guard does not 401 the - * logged-out bounce. The `createAuthPlugin({ publicPaths })` call is - * additive — it appends to the live guard config without disturbing the - * primary Dispatch auth plugin's googleOnly/marketing/onboarding config. + * Dispatch identity-SSO plugin. The primary Dispatch auth plugin owns the + * route's public-path registration; this plugin owns only its handler. */ export default async (nitroApp: any) => { getH3App(nitroApp).use(AUTHORIZE_PATH, authorizeHandler); - return createAuthPlugin({ publicPaths: [AUTHORIZE_PATH] })(nitroApp); }; diff --git a/templates/dispatch/server/plugins/org-apps-directory.ts b/templates/dispatch/server/plugins/org-apps-directory.ts index 7ca5471903..afef343729 100644 --- a/templates/dispatch/server/plugins/org-apps-directory.ts +++ b/templates/dispatch/server/plugins/org-apps-directory.ts @@ -51,11 +51,7 @@ import { getOrgDomain, resolveOrgByDomain, } from "@agent-native/core/org"; -import { - createAuthPlugin, - getH3App, - runWithRequestContext, -} from "@agent-native/core/server"; +import { getH3App, runWithRequestContext } from "@agent-native/core/server"; import { discoverAgents } from "@agent-native/core/server/agent-discovery"; import { defineEventHandler, getMethod, getRequestHeader } from "h3"; import type { H3Event } from "h3"; @@ -181,14 +177,10 @@ const orgAppsHandler = defineEventHandler( ); /** - * Dispatch org-app-directory plugin. Mounts the directory route and - * registers its exact path as a public path so the core auth guard does not - * 401 the A2A peer call before our own JWT + same-org check runs. The - * `createAuthPlugin({ publicPaths })` call is additive — it appends to the - * live guard config without disturbing Dispatch's primary auth plugin - * (same mechanism the identity-sso plugin relies on). + * Dispatch org-app-directory plugin. The primary Dispatch auth plugin owns + * the route's public-path registration so this handler can perform its own + * JWT and same-org checks without racing a second auth initializer. */ export default async (nitroApp: any) => { getH3App(nitroApp).use(ORG_APPS_PATH, orgAppsHandler); - return createAuthPlugin({ publicPaths: [ORG_APPS_PATH] })(nitroApp); }; diff --git a/templates/dispatch/server/plugins/setup-dispatch.ts b/templates/dispatch/server/plugins/setup-dispatch.ts index 381b39d828..17abb3c102 100644 --- a/templates/dispatch/server/plugins/setup-dispatch.ts +++ b/templates/dispatch/server/plugins/setup-dispatch.ts @@ -1,3 +1,10 @@ import { setupDispatch } from "@agent-native/dispatch/server"; -export default setupDispatch(); +export default setupDispatch({ + auth: { + publicPaths: [ + "/_agent-native/identity/authorize", + "/_agent-native/org/apps", + ], + }, +}); From 2c7abf4cf4f843ccd56390852a5a37e3a584227c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Tue, 21 Jul 2026 09:57:12 -0400 Subject: [PATCH 03/31] Sync workspace authentication skill --- .../.agents/skills/authentication/SKILL.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md b/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md index f7e5d4ef67..15f14105ab 100644 --- a/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md +++ b/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md @@ -95,6 +95,24 @@ Each hosted `*.agent-native.com` app has its **own user store**, so "sign in onc - **Invariant (do not break):** identity rows are only ever **added** — never modified, renamed, or deleted. Enabling SSO logs users out, but they always log back into the **same email-matched account with data intact**. Email is the only thing that crosses the trust boundary; the app never trusts a user id, role, or org from the wire. - **Canary rollout:** deploy with the env unset everywhere (no-op) → set it on **one** app (mail) only → verify (logout → SSO → Dispatch → back to the same pre-existing account, data intact, direct logins still work) → expand app-by-app → rollback = unset the env on that app's deploy (instant, no data change). +### Packaged Desktop workspace SSO + +For the signed Desktop app's canonical first-party hosted registry, Electron +owns a dedicated persistent Dispatch identity partition. Each target app still +uses the hosted federation flow above to mint its own normal local session; +Electron transfers only that target app's session cookie into its isolated +`persist:app-` partition. Never distribute a Dispatch cookie, an identity +JWT, or one app's session to another app, and never expose those values through +renderer IPC. + +Custom, user-added, and third-party apps are excluded. Preserve direct web +sign-in and the per-app `AGENT_NATIVE_IDENTITY_HUB_URL` opt-in/canary behavior. +The `desktop-sso.json` broker is only a loopback, non-production local +development compatibility path, not packaged hosted SSO. Desktop workspace +sign-out clears the central identity session and canonical app sessions; +standalone web logout remains app-local. Builder Connect and Builder credentials +are unrelated to human identity and must not join this flow. + Full runbook + flow detail: [Cross-App SSO doc](/docs/cross-app-sso). ## Builder Browser Access From 15f48a8ad54303ef7c4cb3a07bb696daa789bca0 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 06:45:13 -0400 Subject: [PATCH 04/31] Fix Desktop workspace sign-out races --- .github/workflows/desktop-canary.yml | 7 +- .../src/main/desktop-identity.spec.ts | 162 ++++++++++++++++++ .../desktop-app/src/main/desktop-identity.ts | 104 ++++++++++- packages/desktop-app/src/main/index.ts | 6 +- plans/desktop-sso-intent/work-ledger.md | 19 +- 5 files changed, 282 insertions(+), 16 deletions(-) diff --git a/.github/workflows/desktop-canary.yml b/.github/workflows/desktop-canary.yml index d71021c3be..58d3f7d613 100644 --- a/.github/workflows/desktop-canary.yml +++ b/.github/workflows/desktop-canary.yml @@ -61,7 +61,12 @@ jobs: SENTRY_DESKTOP_PROJECT_ID: ${{ secrets.SENTRY_DESKTOP_PROJECT_ID || secrets.SENTRY_PROJECT_ID }} SENTRY_DESKTOP_INGEST_HOST: ${{ secrets.SENTRY_DESKTOP_INGEST_HOST || secrets.SENTRY_INGEST_HOST }} SENTRY_DESKTOP_ENVIRONMENT: canary - run: pnpm build && npx electron-builder --mac --config --publish never + run: | + node ../../scripts/build-branding-assets.mjs + pnpm build:native:mac + pnpm build:chrome-extension + pnpm build + npx electron-builder --mac --config --publish never - name: Verify candidate provenance and trust working-directory: packages/desktop-app diff --git a/packages/desktop-app/src/main/desktop-identity.spec.ts b/packages/desktop-app/src/main/desktop-identity.spec.ts index c6d045b5a0..c9a84de7d5 100644 --- a/packages/desktop-app/src/main/desktop-identity.spec.ts +++ b/packages/desktop-app/src/main/desktop-identity.spec.ts @@ -51,6 +51,14 @@ function appFixture(): DesktopIdentityApp { }; } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("Desktop identity navigation boundaries", () => { const app = appFixture(); @@ -230,4 +238,158 @@ describe("DesktopIdentityBroker", () => { expect(reloadApp).toHaveBeenCalledWith(app); expect(broker.getStatus()).toBe("sign-in-required"); }); + + it("keeps automatic sign-in suppressed until an explicit sign-in", async () => { + const app = appFixture(); + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents: { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }, + loadURL: vi.fn(async () => {}), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + const signInUrl = `${app.origin}/_agent-native/sign-in`; + + await broker.signOut([app]); + expect(broker.handleSignedOutNavigation(app.id, signInUrl)).toBe(false); + + const explicitSignIn = broker.signIn(app.id); + expect(broker.handleSignedOutNavigation(app.id, signInUrl)).toBe(true); + await vi.waitFor(() => expect(closedListener).toBeDefined()); + closedListener?.(); + await expect(explicitSignIn).resolves.toBe(false); + }); + + it("invalidates a queued ceremony before it can open a window", async () => { + const app = appFixture(); + const fetchResponse = deferred(); + const fetch = vi.fn(() => fetchResponse.promise); + const createWindow = vi.fn(); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + fetch, + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + await broker.signOut([app]); + fetchResponse.resolve( + new Response(null, { + status: 302, + headers: { location: "https://dispatch.agent-native.com/sign-in" }, + }), + ); + + await expect(ceremony).resolves.toBe(false); + expect(createWindow).not.toHaveBeenCalled(); + expect(reloadApp).toHaveBeenCalledTimes(1); + }); + + it("removes a cookie written by a ceremony cancelled during sign-out", async () => { + const app = appFixture(); + const identityCookies = cookieStore([ + { + name: "an_session_mail", + value: "example-session-value", + domain: "mail.agent-native.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + session: true, + sameSite: "lax", + }, + ]); + const cookieWrite = deferred(); + const targetCookies = cookieStore(); + targetCookies.set.mockImplementation(async () => cookieWrite.promise); + app.session = { cookies: targetCookies } as unknown as Electron.Session; + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const closedListeners: Array<() => void> = []; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListeners.push(listener); + }), + }; + const createWindow = vi.fn(() => identityWindow as never); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "will-navigate", + )?.[1]; + navigationHandler({ preventDefault: vi.fn() }, completion); + await vi.waitFor(() => expect(targetCookies.set).toHaveBeenCalledOnce()); + + let signOutResolved = false; + const signOutOperation = broker.signOut([app]); + const signOut = signOutOperation.then(() => { + signOutResolved = true; + }); + const nextSignIn = broker.signIn(app.id); + expect(broker.signOut([app])).toBe(signOutOperation); + await Promise.resolve(); + expect(signOutResolved).toBe(false); + expect(createWindow).toHaveBeenCalledOnce(); + cookieWrite.resolve(); + + await signOut; + expect(signOutResolved).toBe(true); + await expect(ceremony).resolves.toBe(false); + await expect(nextSignIn).resolves.toBe(false); + expect(targetCookies.remove).toHaveBeenCalledWith( + app.origin, + "an_session_mail", + ); + expect(closedListeners).toHaveLength(1); + expect(createWindow).toHaveBeenCalledOnce(); + expect(reloadApp).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/desktop-app/src/main/desktop-identity.ts b/packages/desktop-app/src/main/desktop-identity.ts index 7c596a6030..f7b8da8d59 100644 --- a/packages/desktop-app/src/main/desktop-identity.ts +++ b/packages/desktop-app/src/main/desktop-identity.ts @@ -114,9 +114,13 @@ export function isDesktopIdentityCompletion( export class DesktopIdentityBroker { private readonly pendingByApp = new Map>(); private readonly unsupportedAppIds = new Set(); + private readonly activeSessionCopies = new Set>(); private queue: Promise = Promise.resolve(); private activeWindow: DesktopIdentityWindow | null = null; + private signOutOperation: Promise | null = null; private status: DesktopIdentityStatus = "idle"; + private ceremonyGeneration = 0; + private automaticSignInSuppressed = false; constructor(private readonly options: DesktopIdentityBrokerOptions) {} @@ -144,6 +148,7 @@ export class DesktopIdentityBroker { const app = this.options.resolveApp(appId); if ( !app || + this.automaticSignInSuppressed || this.unsupportedAppIds.has(appId) || !isDesktopSignInNavigation(navigationUrl, app) ) { @@ -157,7 +162,11 @@ export class DesktopIdentityBroker { const existing = this.pendingByApp.get(appId); if (existing) return existing; - const operation = this.queue.then(() => this.runCeremony(appId)); + const generation = this.ceremonyGeneration; + const operation = this.queue.then(async () => { + await this.signOutOperation; + return this.runCeremony(appId, generation); + }); this.queue = operation.then( () => undefined, () => undefined, @@ -171,8 +180,34 @@ export class DesktopIdentityBroker { return operation; } - async signOut(apps: DesktopIdentityApp[]): Promise { + signIn(appId: string): Promise { + this.automaticSignInSuppressed = false; + this.unsupportedAppIds.delete(appId); + return this.ensureAppSession(appId); + } + + signOut(apps: DesktopIdentityApp[]): Promise { + this.automaticSignInSuppressed = true; + this.ceremonyGeneration += 1; + this.pendingByApp.clear(); this.closeActiveWindow(); + if (this.signOutOperation) return this.signOutOperation; + + const operation = this.finishSignOut(apps); + this.signOutOperation = operation; + void operation.then( + () => { + if (this.signOutOperation === operation) this.signOutOperation = null; + }, + () => { + if (this.signOutOperation === operation) this.signOutOperation = null; + }, + ); + return operation; + } + + private async finishSignOut(apps: DesktopIdentityApp[]): Promise { + await this.waitForActiveSessionCopies(); await this.options.identitySession.clearStorageData({ storages: ["cookies"], }); @@ -189,7 +224,11 @@ export class DesktopIdentityBroker { this.setStatus("sign-in-required"); } - private async runCeremony(appId: string): Promise { + private async runCeremony( + appId: string, + generation: number, + ): Promise { + if (!this.isCeremonyCurrent(generation)) return false; const app = this.options.resolveApp(appId); if (!app) return false; @@ -208,6 +247,7 @@ export class DesktopIdentityBroker { const response = await this.options.identitySession.fetch(initialUrl, { redirect: "manual", }); + if (!this.isCeremonyCurrent(generation)) return false; const location = response.headers.get("location"); if (response.status < 300 || response.status >= 400 || !location) { this.unsupportedAppIds.add(app.id); @@ -217,6 +257,7 @@ export class DesktopIdentityBroker { } initialUrl = new URL(location, initialUrl).toString(); } catch { + if (!this.isCeremonyCurrent(generation)) return false; this.unsupportedAppIds.add(app.id); this.setStatus("failed"); this.options.reloadApp(app); @@ -224,6 +265,8 @@ export class DesktopIdentityBroker { } } + if (!this.isCeremonyCurrent(generation)) return false; + const identityWindow = this.options.createWindow({ width: 520, height: 720, @@ -247,7 +290,7 @@ export class DesktopIdentityBroker { settled = true; if (timer) clearTimeout(timer); if (this.activeWindow === identityWindow) this.activeWindow = null; - this.setStatus(status); + if (this.isCeremonyCurrent(generation)) this.setStatus(status); if (!identityWindow.isDestroyed()) identityWindow.close(); resolve(ok); }; @@ -255,8 +298,18 @@ export class DesktopIdentityBroker { const inspectNavigation = (event: Electron.Event, url: string) => { if (isDesktopIdentityCompletion(url, app, nonce)) { event.preventDefault(); - void this.copyTargetSession(app).then( + if (!this.isCeremonyCurrent(generation)) { + finish(false, "sign-in-required"); + return; + } + void this.trackSessionCopy( + this.copyTargetSession(app, generation), + ).then( () => { + if (!this.isCeremonyCurrent(generation)) { + finish(false, "sign-in-required"); + return; + } this.options.reloadApp(app); finish(true, "signed-in"); }, @@ -296,18 +349,25 @@ export class DesktopIdentityBroker { }); } - private async copyTargetSession(app: DesktopIdentityApp): Promise { + private async copyTargetSession( + app: DesktopIdentityApp, + generation: number, + ): Promise { + this.assertCeremonyCurrent(generation); const sourceCookies = await this.options.identitySession.cookies.get({ url: app.origin, }); + this.assertCeremonyCurrent(generation); const allowed = new Set(app.cookieNames); const cookies = sourceCookies.filter((cookie) => allowed.has(cookie.name)); if (cookies.length === 0) throw new Error("Missing app session cookie"); for (const cookieName of app.cookieNames) { + this.assertCeremonyCurrent(generation); await app.session.cookies.remove(app.origin, cookieName).catch(() => {}); } for (const cookie of cookies) { + this.assertCeremonyCurrent(generation); await app.session.cookies.set({ url: app.origin, name: cookie.name, @@ -320,10 +380,17 @@ export class DesktopIdentityBroker { ? { expirationDate: cookie.expirationDate } : {}), }); + if (!this.isCeremonyCurrent(generation)) { + await app.session.cookies + .remove(app.origin, cookie.name) + .catch(() => {}); + this.assertCeremonyCurrent(generation); + } } if (!app.identityAuthority) { for (const cookie of cookies) { + this.assertCeremonyCurrent(generation); await this.options.identitySession.cookies .remove(app.origin, cookie.name) .catch(() => {}); @@ -337,6 +404,31 @@ export class DesktopIdentityBroker { if (active && !active.isDestroyed()) active.close(); } + private trackSessionCopy(operation: Promise): Promise { + this.activeSessionCopies.add(operation); + void operation.then( + () => this.activeSessionCopies.delete(operation), + () => this.activeSessionCopies.delete(operation), + ); + return operation; + } + + private async waitForActiveSessionCopies(): Promise { + while (this.activeSessionCopies.size > 0) { + await Promise.allSettled([...this.activeSessionCopies]); + } + } + + private isCeremonyCurrent(generation: number): boolean { + return generation === this.ceremonyGeneration; + } + + private assertCeremonyCurrent(generation: number): void { + if (!this.isCeremonyCurrent(generation)) { + throw new Error("Desktop identity ceremony was cancelled"); + } + } + private setStatus(status: DesktopIdentityStatus): void { this.status = status; this.options.onStatus?.(status); diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index dd4b1eb9df..86093f2fb8 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -1042,7 +1042,11 @@ ipcMain.handle(IPC.IDENTITY_STATUS_GET, (event) => { ipcMain.handle(IPC.IDENTITY_SIGN_IN, async (event) => { if (!isShellIdentityIpc(event) || !desktopIdentityBroker) return false; - return desktopIdentityBroker.ensureAppSession("dispatch"); + const targetApp = + resolveDesktopIdentityApp("mail") ?? + listDesktopIdentityApps().find((candidate) => !candidate.identityAuthority); + if (!targetApp) return false; + return desktopIdentityBroker.signIn(targetApp.id); }); ipcMain.handle(IPC.IDENTITY_SIGN_OUT, async (event) => { diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index e2b92a71c8..435a21b4db 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -1,8 +1,8 @@ # Desktop workspace SSO work ledger ```yaml -stage: work -authority-source: "Alice: All right. Log that plan to the vault and work it." +stage: land +authority-source: "Alice: $land but don't merge yet." authorized-scope: repositories: - BuilderIO/agent-native @@ -74,8 +74,8 @@ delegation-ceiling: product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: - status: pending - summary: implementation and automated verification are complete; live canary and signed installed-app acceptance remain outstanding + status: blocked + summary: the PR is approved and mergeable, and the review findings are fixed locally; final-head CI, a fresh signed artifact, and real-app acceptance still block handoff readiness verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -86,6 +86,10 @@ acceptance-state: - Dispatch package suite: 269 tests passed; package build and typecheck passed on Framework - Dispatch template suite: 40 tests passed; template typecheck passed on Framework - production-shaped Netlify bundle changed the valid logged-out identity authorize request from 401 to the expected 302 sign-in redirect + - Desktop identity regression suite: 8 tests passed, including sign-out suppression, queued cancellation, cookie-write draining, and immediate explicit reauthentication ordering + - final-fix Desktop TypeScript passed + - final-fix formatting, focused lint, and git diff checks passed + - independent final technical review found no remaining actionable issues after the repeated sign-out concurrency regression was added implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -97,9 +101,8 @@ acceptance-state: - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption blockers: - - signed packaging depends on the repository's GitHub-hosted Apple signing and notarization secrets - - production promotion requires green CI, a successful signed candidate, human review, and freshly captured rollback evidence - - the signed installed-app matrix still needs restart, account switch, standalone browser, custom app, local-development, and hostile-flow verification against the canary + - final-head CI must pass, including a clean Fast Tests run under the repository's supported Node 22 environment + - the material review fixes require a new signed artifact and independent real-app acceptance across the frozen restart, sign-out, account-switch, isolation, and hostile-flow matrix last-land-packet: null deployment-boundary: allowed: @@ -112,6 +115,6 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-work-r5 +ledger-revision: desktop-sso-land-r8 status: active ``` From 8d33ffffe9e8b0b3acc78e725736a96f97dae7e5 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 08:06:37 -0400 Subject: [PATCH 05/31] Preserve Desktop workspace logout semantics --- .../src/main/desktop-identity.spec.ts | 277 ++++++++++++++- .../desktop-app/src/main/desktop-identity.ts | 324 +++++++++++++++++- packages/desktop-app/src/main/index.ts | 126 ++++++- plans/desktop-sso-intent/work-ledger.md | 10 +- 4 files changed, 695 insertions(+), 42 deletions(-) diff --git a/packages/desktop-app/src/main/desktop-identity.spec.ts b/packages/desktop-app/src/main/desktop-identity.spec.ts index c9a84de7d5..3f4d1081dd 100644 --- a/packages/desktop-app/src/main/desktop-identity.spec.ts +++ b/packages/desktop-app/src/main/desktop-identity.spec.ts @@ -3,7 +3,9 @@ import { describe, expect, it, vi } from "vitest"; import { DESKTOP_IDENTITY_COMPLETE_PATH, DesktopIdentityBroker, + desktopWorkspaceLogoutPath, isDesktopIdentityCompletion, + isDesktopIdentityConfiguredAppEligible, isDesktopSignInNavigation, isDesktopWorkspaceLogoutRequest, type DesktopIdentityApp, @@ -47,7 +49,10 @@ function appFixture(): DesktopIdentityApp { "an_mail.session_token", "__Secure-an_mail.session_token", ], - session: { cookies: cookieStore() } as unknown as Electron.Session, + session: { + cookies: cookieStore(), + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } as unknown as Electron.Session, }; } @@ -59,6 +64,24 @@ function deferred() { return { promise, resolve }; } +function sessionCookie( + name: string, + origin: string, + value = "example-session-value", +): Electron.Cookie { + return { + name, + value, + domain: new URL(origin).hostname, + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + session: true, + sameSite: "lax", + }; +} + describe("Desktop identity navigation boundaries", () => { const app = appFixture(); @@ -121,6 +144,30 @@ describe("Desktop identity navigation boundaries", () => { app, ), ).toBe(false); + expect( + desktopWorkspaceLogoutPath( + "https://mail.agent-native.com/_agent-native/auth/logout-all", + app, + ), + ).toBe("/_agent-native/auth/logout-all"); + }); + + it("includes disabled or dev canonical apps only in cleanup inventory", () => { + for (const id of ["mail", "dispatch"]) { + const configured = { id, enabled: false, mode: "prod" }; + expect(isDesktopIdentityConfiguredAppEligible(configured)).toBe(false); + expect( + isDesktopIdentityConfiguredAppEligible(configured, { + forCleanup: true, + }), + ).toBe(true); + } + expect( + isDesktopIdentityConfiguredAppEligible( + { enabled: false, mode: "dev" }, + { forCleanup: true }, + ), + ).toBe(true); }); }); @@ -212,11 +259,13 @@ describe("DesktopIdentityBroker", () => { expect(closedListener).toBeDefined(); }); - it("clears only canonical app cookies plus the central identity session", async () => { + it("revokes and clears canonical app sessions plus the central identity session", async () => { const app = appFixture(); + app.identityAuthority = true; const identitySession = { cookies: cookieStore(), clearStorageData: vi.fn(async () => {}), + fetch: vi.fn(async () => new Response(null, { status: 200 })), } as unknown as Electron.Session; const clearLocalBroker = vi.fn(); const reloadApp = vi.fn(); @@ -230,6 +279,18 @@ describe("DesktopIdentityBroker", () => { await broker.signOut([app]); + expect(app.session.fetch).toHaveBeenCalledWith( + expect.stringMatching( + /^https:\/\/mail\.agent-native\.com\/_agent-native\/auth\/logout\?_an_desktop_logout=/, + ), + { method: "POST", redirect: "manual", credentials: "include" }, + ); + expect(identitySession.fetch).toHaveBeenCalledWith( + expect.stringMatching( + /^https:\/\/mail\.agent-native\.com\/_agent-native\/auth\/logout\?_an_desktop_logout=/, + ), + { method: "POST", redirect: "manual", credentials: "include" }, + ); expect(identitySession.clearStorageData).toHaveBeenCalledWith({ storages: ["cookies"], }); @@ -239,6 +300,213 @@ describe("DesktopIdentityBroker", () => { expect(broker.getStatus()).toBe("sign-in-required"); }); + it("attempts every local cleanup and reports failure when central cleanup fails", async () => { + const app = appFixture(); + const identitySession = { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => { + throw new Error("central cleanup failed"); + }), + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } as unknown as Electron.Session; + const clearLocalBroker = vi.fn(); + const reloadApp = vi.fn(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const broker = new DesktopIdentityBroker({ + identitySession, + resolveApp: () => app, + createWindow: vi.fn() as never, + reloadApp, + clearLocalBroker, + }); + + await broker.signOut([app]); + + expect(app.session.cookies.remove).toHaveBeenCalledTimes(4); + expect(clearLocalBroker).toHaveBeenCalledOnce(); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("failed"); + expect(consoleError).toHaveBeenCalledOnce(); + consoleError.mockRestore(); + }); + + it("preserves logout-all while avoiding a duplicate request to the triggering app", async () => { + const app = appFixture(); + app.identityAuthority = true; + const identitySession = { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } as unknown as Electron.Session; + const broker = new DesktopIdentityBroker({ + identitySession, + resolveApp: () => app, + createWindow: vi.fn() as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + await broker.signOut([app], { + logoutPath: "/_agent-native/auth/logout-all", + alreadyRevokedAppId: app.id, + }); + + expect(app.session.fetch).not.toHaveBeenCalled(); + expect(identitySession.fetch).toHaveBeenCalledWith( + expect.stringMatching( + /^https:\/\/mail\.agent-native\.com\/_agent-native\/auth\/logout-all\?_an_desktop_logout=/, + ), + { method: "POST", redirect: "manual", credentials: "include" }, + ); + expect(broker.getStatus()).toBe("sign-in-required"); + }); + + it("upgrades an in-progress logout when logout-all arrives", async () => { + const mail = appFixture(); + mail.identityAuthority = true; + const calendar = appFixture(); + calendar.id = "calendar"; + calendar.origin = "https://calendar.agent-native.com"; + const plainLogout = deferred(); + const sessionFetch = () => + vi.fn((url: string) => + new URL(url).pathname === "/_agent-native/auth/logout" + ? plainLogout.promise + : Promise.resolve(new Response(null, { status: 200 })), + ); + mail.session.fetch = sessionFetch(); + calendar.session.fetch = sessionFetch(); + const identityFetch = sessionFetch(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + fetch: identityFetch, + } as unknown as Electron.Session, + resolveApp: (id) => [mail, calendar].find((app) => app.id === id) ?? null, + createWindow: vi.fn() as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const first = broker.signOut([mail, calendar], { + alreadyRevokedAppId: mail.id, + }); + await vi.waitFor(() => expect(calendar.session.fetch).toHaveBeenCalled()); + const second = broker.signOut([mail, calendar], { + logoutPath: "/_agent-native/auth/logout-all", + alreadyRevokedAppId: calendar.id, + }); + expect(second).toBe(first); + plainLogout.resolve(new Response(null, { status: 200 })); + + await first; + + expect(mail.session.fetch).toHaveBeenCalledWith( + expect.stringMatching( + /^https:\/\/mail\.agent-native\.com\/_agent-native\/auth\/logout-all\?_an_desktop_logout=/, + ), + { method: "POST", redirect: "manual", credentials: "include" }, + ); + expect( + vi + .mocked(calendar.session.fetch) + .mock.calls.some( + ([url]) => + new URL(String(url)).pathname === "/_agent-native/auth/logout-all", + ), + ).toBe(false); + expect(identityFetch).toHaveBeenCalledWith( + expect.stringMatching( + /^https:\/\/mail\.agent-native\.com\/_agent-native\/auth\/logout-all\?_an_desktop_logout=/, + ), + { method: "POST", redirect: "manual", credentials: "include" }, + ); + }); + + it("uses request-start credentials when logout-all arrives during cleanup", async () => { + const mail = appFixture(); + mail.identityAuthority = true; + const calendar = appFixture(); + calendar.id = "calendar"; + calendar.origin = "https://calendar.agent-native.com"; + const mailFetch = vi.fn( + async (_url: string, _init?: RequestInit) => + new Response(null, { status: 200 }), + ); + const calendarFetch = vi.fn( + async (_url: string, _init?: RequestInit) => + new Response(null, { status: 200 }), + ); + mail.session = { + cookies: cookieStore([ + sessionCookie("an_session_mail", mail.origin, "mail-session"), + ]), + fetch: mailFetch, + } as unknown as Electron.Session; + calendar.session = { + cookies: cookieStore([ + sessionCookie("an_session", calendar.origin, "calendar-session"), + ]), + fetch: calendarFetch, + } as unknown as Electron.Session; + const centralCleanup = deferred(); + const identityFetch = vi.fn( + async (_url: string, _init?: RequestInit) => + new Response(null, { status: 200 }), + ); + const clearStorageData = vi.fn(() => centralCleanup.promise); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore([ + sessionCookie("an_session", mail.origin, "dispatch-session"), + ]), + clearStorageData, + fetch: identityFetch, + } as unknown as Electron.Session, + resolveApp: (id) => [mail, calendar].find((app) => app.id === id) ?? null, + createWindow: vi.fn() as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + const apps = [mail, calendar]; + const plainOptions = { + logoutPath: "/_agent-native/auth/logout" as const, + alreadyRevokedAppId: mail.id, + }; + await broker.prepareExternalSignOut(apps, plainOptions); + const signOut = broker.completeExternalSignOut(apps, plainOptions, true); + await vi.waitFor(() => expect(clearStorageData).toHaveBeenCalled()); + + const allOptions = { + logoutPath: "/_agent-native/auth/logout-all" as const, + alreadyRevokedAppId: calendar.id, + }; + await broker.prepareExternalSignOut(apps, allOptions); + void broker.completeExternalSignOut(apps, allOptions, true); + centralCleanup.resolve(); + await signOut; + + expect( + mailFetch.mock.calls.some( + ([url, init]) => + new URL(String(url)).pathname === "/_agent-native/auth/logout-all" && + new Headers(init?.headers).get("Cookie") === + "an_session_mail=mail-session", + ), + ).toBe(true); + expect( + identityFetch.mock.calls.some( + ([url, init]) => + new URL(String(url)).pathname === "/_agent-native/auth/logout-all" && + new Headers(init?.headers).get("Cookie") === + "an_session=dispatch-session", + ), + ).toBe(true); + }); + it("keeps automatic sign-in suppressed until an explicit sign-in", async () => { const app = appFixture(); let closedListener: (() => void) | undefined; @@ -327,7 +595,10 @@ describe("DesktopIdentityBroker", () => { const cookieWrite = deferred(); const targetCookies = cookieStore(); targetCookies.set.mockImplementation(async () => cookieWrite.promise); - app.session = { cookies: targetCookies } as unknown as Electron.Session; + app.session = { + cookies: targetCookies, + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } as unknown as Electron.Session; const webContents = { on: vi.fn(), setWindowOpenHandler: vi.fn(), diff --git a/packages/desktop-app/src/main/desktop-identity.ts b/packages/desktop-app/src/main/desktop-identity.ts index f7b8da8d59..93432e3d47 100644 --- a/packages/desktop-app/src/main/desktop-identity.ts +++ b/packages/desktop-app/src/main/desktop-identity.ts @@ -14,8 +14,14 @@ export const DESKTOP_IDENTITY_COMPLETE_PATH = const DESKTOP_SIGN_IN_PATH = "/_agent-native/sign-in"; const DESKTOP_IDENTITY_LOGIN_PATH = "/_agent-native/identity/login"; +const DESKTOP_LOGOUT_PATH = "/_agent-native/auth/logout"; +const DESKTOP_LOGOUT_ALL_PATH = "/_agent-native/auth/logout-all"; const DEFAULT_CEREMONY_TIMEOUT_MS = 5 * 60 * 1000; +export type DesktopWorkspaceLogoutPath = + | typeof DESKTOP_LOGOUT_PATH + | typeof DESKTOP_LOGOUT_ALL_PATH; + export type DesktopIdentityStatus = | "idle" | "signing-in" @@ -32,19 +38,42 @@ export interface DesktopIdentityApp { identityAuthority?: boolean; } +export function isDesktopIdentityConfiguredAppEligible< + T extends { enabled?: boolean; mode?: string }, +>( + configured: T | null | undefined, + options?: { forCleanup?: boolean }, +): configured is T { + return Boolean( + configured && + (options?.forCleanup || + (configured.mode !== "dev" && configured.enabled !== false)), + ); +} + export function isDesktopWorkspaceLogoutRequest( requestUrl: string, app: Pick, ): boolean { + return desktopWorkspaceLogoutPath(requestUrl, app) !== null; +} + +export function desktopWorkspaceLogoutPath( + requestUrl: string, + app: Pick, +): DesktopWorkspaceLogoutPath | null { try { const parsed = new URL(requestUrl); - return ( - parsed.origin === app.origin && - (parsed.pathname === "/_agent-native/auth/logout" || - parsed.pathname === "/_agent-native/auth/logout-all") - ); + if (parsed.origin !== app.origin) return null; + if ( + parsed.pathname === DESKTOP_LOGOUT_PATH || + parsed.pathname === DESKTOP_LOGOUT_ALL_PATH + ) { + return parsed.pathname; + } + return null; } catch { - return false; + return null; } } @@ -74,6 +103,18 @@ export interface DesktopIdentityBrokerOptions { timeoutMs?: number; } +interface DesktopSignOutIntent { + logoutPath: DesktopWorkspaceLogoutPath; + alreadyRevokedAppIds: Set; +} + +interface DesktopRevocationTarget { + appId: string | null; + origin: string; + session: Session; + cookieHeader: string; +} + export function isDesktopSignInNavigation( navigationUrl: string, app: Pick, @@ -118,6 +159,14 @@ export class DesktopIdentityBroker { private queue: Promise = Promise.resolve(); private activeWindow: DesktopIdentityWindow | null = null; private signOutOperation: Promise | null = null; + private signOutIntent: DesktopSignOutIntent | null = null; + private revocationTargets: DesktopRevocationTarget[] | null = null; + private revocationTargetErrors: unknown[] = []; + private revocationTargetsPromise: Promise | null = null; + private externalSignOutRequests = 0; + private readonly externalSignOutWaiters = new Set<() => void>(); + private readonly internalRevocationNonce = + randomBytes(16).toString("base64url"); private status: DesktopIdentityStatus = "idle"; private ceremonyGeneration = 0; private automaticSignInSuppressed = false; @@ -128,6 +177,17 @@ export class DesktopIdentityBroker { return this.status; } + isInternalRevocationRequest(requestUrl: string): boolean { + try { + return ( + new URL(requestUrl).searchParams.get("_an_desktop_logout") === + this.internalRevocationNonce + ); + } catch { + return false; + } + } + async refreshStatus(authorityApp: DesktopIdentityApp | null): Promise { if (!authorityApp) { this.setStatus("idle"); @@ -186,44 +246,270 @@ export class DesktopIdentityBroker { return this.ensureAppSession(appId); } - signOut(apps: DesktopIdentityApp[]): Promise { + async prepareExternalSignOut( + apps: DesktopIdentityApp[], + options: { + logoutPath: DesktopWorkspaceLogoutPath; + alreadyRevokedAppId: string; + }, + ): Promise { + this.externalSignOutRequests += 1; + this.updateSignOutIntent({ logoutPath: options.logoutPath }); + await this.ensureRevocationTargets(apps); + } + + completeExternalSignOut( + apps: DesktopIdentityApp[], + options: { + logoutPath: DesktopWorkspaceLogoutPath; + alreadyRevokedAppId: string; + }, + succeeded: boolean, + ): Promise { + this.externalSignOutRequests = Math.max( + 0, + this.externalSignOutRequests - 1, + ); + if (this.externalSignOutRequests === 0) { + for (const resolve of this.externalSignOutWaiters) resolve(); + this.externalSignOutWaiters.clear(); + } + if (succeeded) return this.signOut(apps, options); + if (!this.signOutOperation && this.externalSignOutRequests === 0) { + this.resetSignOutState(); + } + return Promise.resolve(); + } + + signOut( + apps: DesktopIdentityApp[], + options?: { + logoutPath?: DesktopWorkspaceLogoutPath; + alreadyRevokedAppId?: string; + }, + ): Promise { this.automaticSignInSuppressed = true; this.ceremonyGeneration += 1; this.pendingByApp.clear(); this.closeActiveWindow(); + this.updateSignOutIntent(options); if (this.signOutOperation) return this.signOutOperation; - const operation = this.finishSignOut(apps); + const intent = this.signOutIntent!; + const operation = this.finishSignOut(apps, intent); this.signOutOperation = operation; void operation.then( () => { - if (this.signOutOperation === operation) this.signOutOperation = null; + if (this.signOutOperation === operation) { + this.signOutOperation = null; + this.resetSignOutState(); + } }, () => { - if (this.signOutOperation === operation) this.signOutOperation = null; + if (this.signOutOperation === operation) { + this.signOutOperation = null; + this.resetSignOutState(); + } }, ); return operation; } - private async finishSignOut(apps: DesktopIdentityApp[]): Promise { + private updateSignOutIntent(options?: { + logoutPath?: DesktopWorkspaceLogoutPath; + alreadyRevokedAppId?: string; + }): void { + const requestedPath = options?.logoutPath ?? DESKTOP_LOGOUT_PATH; + if (!this.signOutIntent) { + this.signOutIntent = { + logoutPath: requestedPath, + alreadyRevokedAppIds: new Set( + options?.alreadyRevokedAppId ? [options.alreadyRevokedAppId] : [], + ), + }; + return; + } + + if ( + requestedPath === DESKTOP_LOGOUT_ALL_PATH && + this.signOutIntent.logoutPath !== DESKTOP_LOGOUT_ALL_PATH + ) { + this.signOutIntent.logoutPath = DESKTOP_LOGOUT_ALL_PATH; + this.signOutIntent.alreadyRevokedAppIds.clear(); + } + if ( + requestedPath === this.signOutIntent.logoutPath && + options?.alreadyRevokedAppId + ) { + this.signOutIntent.alreadyRevokedAppIds.add(options.alreadyRevokedAppId); + } + } + + private async finishSignOut( + apps: DesktopIdentityApp[], + intent: DesktopSignOutIntent, + ): Promise { await this.waitForActiveSessionCopies(); - await this.options.identitySession.clearStorageData({ - storages: ["cookies"], - }); + await this.ensureRevocationTargets(apps); + const errors = [...this.revocationTargetErrors]; + let completedPath: DesktopWorkspaceLogoutPath | null = null; + do { + const logoutPath = intent.logoutPath; + const alreadyRevokedAppIds = new Set(intent.alreadyRevokedAppIds); + const revocations = (this.revocationTargets ?? []) + .filter( + (target) => !target.appId || !alreadyRevokedAppIds.has(target.appId), + ) + .map((target) => this.revokeSession(target, logoutPath)); + for (const result of await Promise.allSettled(revocations)) { + if (result.status === "rejected") errors.push(result.reason); + } + completedPath = logoutPath; + await Promise.resolve(); + } while (intent.logoutPath !== completedPath); + + try { + await this.options.identitySession.clearStorageData({ + storages: ["cookies"], + }); + } catch (error) { + errors.push(error); + } for (const app of apps) { for (const cookieName of app.cookieNamesToClear) { - await app.session.cookies - .remove(app.origin, cookieName) - .catch(() => {}); + try { + await app.session.cookies.remove(app.origin, cookieName); + } catch (error) { + errors.push(error); + } + } + try { + this.options.reloadApp(app); + } catch (error) { + errors.push(error); } - this.options.reloadApp(app); } - await this.options.clearLocalBroker(); + try { + await this.options.clearLocalBroker(); + } catch (error) { + errors.push(error); + } + + await this.waitForExternalSignOutRequests(); + while (intent.logoutPath !== completedPath) { + const logoutPath: DesktopWorkspaceLogoutPath = intent.logoutPath; + const alreadyRevokedAppIds = new Set(intent.alreadyRevokedAppIds); + const revocations = (this.revocationTargets ?? []) + .filter( + (target) => !target.appId || !alreadyRevokedAppIds.has(target.appId), + ) + .map((target) => this.revokeSession(target, logoutPath)); + for (const result of await Promise.allSettled(revocations)) { + if (result.status === "rejected") errors.push(result.reason); + } + completedPath = logoutPath; + } + if (errors.length > 0) { + console.error( + "[desktop identity] Workspace sign-out completed with failures", + new AggregateError(errors), + ); + this.setStatus("failed"); + return; + } this.setStatus("sign-in-required"); } + private async revokeSession( + target: DesktopRevocationTarget, + logoutPath: DesktopWorkspaceLogoutPath, + ): Promise { + const logoutUrl = new URL(logoutPath, target.origin); + logoutUrl.searchParams.set( + "_an_desktop_logout", + this.internalRevocationNonce, + ); + const response = await target.session.fetch(logoutUrl.toString(), { + method: "POST", + redirect: "manual", + credentials: "include", + ...(target.cookieHeader + ? { headers: { Cookie: target.cookieHeader } } + : {}), + }); + if (!response.ok && response.status !== 401) { + throw new Error( + `Workspace sign-out failed for ${target.origin} (${response.status})`, + ); + } + } + + private async ensureRevocationTargets( + apps: DesktopIdentityApp[], + ): Promise { + if (!this.revocationTargetsPromise) { + this.revocationTargetsPromise = (async () => { + const authority = apps.find((app) => app.identityAuthority); + const candidates = [ + ...apps.map((app) => ({ + appId: app.id, + origin: app.origin, + session: app.session, + cookieNames: app.cookieNamesToClear, + })), + ...(authority + ? [ + { + appId: null, + origin: authority.origin, + session: this.options.identitySession, + cookieNames: authority.cookieNamesToClear, + }, + ] + : []), + ]; + const targets: DesktopRevocationTarget[] = []; + for (const candidate of candidates) { + try { + const cookies = await candidate.session.cookies.get({ + url: candidate.origin, + }); + const allowed = new Set(candidate.cookieNames); + targets.push({ + appId: candidate.appId, + origin: candidate.origin, + session: candidate.session, + cookieHeader: cookies + .filter((cookie) => allowed.has(cookie.name)) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; "), + }); + } catch (error) { + this.revocationTargetErrors.push(error); + } + } + this.revocationTargets = targets; + })(); + } + await this.revocationTargetsPromise; + } + + private async waitForExternalSignOutRequests(): Promise { + while (this.externalSignOutRequests > 0) { + await new Promise((resolve) => + this.externalSignOutWaiters.add(resolve), + ); + } + } + + private resetSignOutState(): void { + this.signOutIntent = null; + this.revocationTargets = null; + this.revocationTargetErrors = []; + this.revocationTargetsPromise = null; + } + private async runCeremony( appId: string, generation: number, diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 3e71b3568e..83418417e5 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -169,7 +169,8 @@ import { DesktopDesignPreviewManager } from "./design-preview-manager"; import { DESKTOP_IDENTITY_PARTITION, DesktopIdentityBroker, - isDesktopWorkspaceLogoutRequest, + desktopWorkspaceLogoutPath, + isDesktopIdentityConfiguredAppEligible, type DesktopIdentityApp, } from "./desktop-identity"; import { @@ -552,26 +553,31 @@ function getInjectionTargetForAppId( }; } -function resolveDesktopIdentityApp(appId: string): DesktopIdentityApp | null { +function resolveDesktopIdentityApp( + appId: string, + options?: { forCleanup?: boolean }, +): DesktopIdentityApp | null { if (!app.isPackaged) return null; const canonical = DESKTOP_DEFAULT_APPS.find( (candidate) => candidate.id === appId, ); + if (!canonical) return null; + const canonicalOrigin = getAppOrigin({ ...canonical, mode: "prod" }); + if (!canonicalOrigin || !canonicalOrigin.startsWith("https://")) return null; + const configured = loadAppsForAuthContext().find( - (candidate) => candidate.id === appId && candidate.enabled !== false, + (candidate) => candidate.id === appId, ); - if (!canonical || !configured || configured.mode === "dev") return null; - - const canonicalOrigin = getAppOrigin({ ...canonical, mode: "prod" }); - const configuredOrigin = getAppOrigin(configured); if ( - !canonicalOrigin || - !configuredOrigin || - canonicalOrigin !== configuredOrigin || - !canonicalOrigin.startsWith("https://") + !options?.forCleanup && + !isDesktopIdentityConfiguredAppEligible(configured) ) { return null; } + if (!options?.forCleanup) { + const configuredOrigin = getAppOrigin(configured!); + if (!configuredOrigin || canonicalOrigin !== configuredOrigin) return null; + } const primaryCookieName = getCookieNameForApp(appId); const appSlug = primaryCookieName.replace(/^an_session_/, ""); @@ -602,6 +608,12 @@ function listDesktopIdentityApps(): DesktopIdentityApp[] { ).filter((candidate): candidate is DesktopIdentityApp => candidate !== null); } +function listDesktopIdentityCleanupApps(): DesktopIdentityApp[] { + return DESKTOP_DEFAULT_APPS.map((candidate) => + resolveDesktopIdentityApp(candidate.id, { forCleanup: true }), + ).filter((candidate): candidate is DesktopIdentityApp => candidate !== null); +} + function getOAuthInjectionTarget( sourceSession: Electron.Session | undefined, sourceUrl: string | undefined, @@ -1051,7 +1063,7 @@ ipcMain.handle(IPC.IDENTITY_SIGN_IN, async (event) => { ipcMain.handle(IPC.IDENTITY_SIGN_OUT, async (event) => { if (!isShellIdentityIpc(event) || !desktopIdentityBroker) return false; - await desktopIdentityBroker.signOut(listDesktopIdentityApps()); + await desktopIdentityBroker.signOut(listDesktopIdentityCleanupApps()); return true; }); @@ -9075,7 +9087,7 @@ function installApplicationMenu() { { label: "Sign Out of Agent Native", click: () => - void desktopIdentityBroker?.signOut(listDesktopIdentityApps()), + void desktopIdentityBroker?.signOut(listDesktopIdentityCleanupApps()), }, { type: "separator" as const }, { role: "services" as const }, @@ -9294,16 +9306,34 @@ app.whenReady().then(async () => { const identityApp = targetAppId ? resolveDesktopIdentityApp(targetAppId) : null; + const logoutPath = identityApp + ? desktopWorkspaceLogoutPath(details.url, identityApp) + : null; if ( identityApp && + logoutPath && details.method === "POST" && - isDesktopWorkspaceLogoutRequest(details.url, identityApp) + desktopIdentityBroker && + !desktopIdentityBroker.isInternalRevocationRequest(details.url) ) { - callback({ cancel: true }); - void desktopIdentityBroker?.signOut(listDesktopIdentityApps()); + const apps = listDesktopIdentityCleanupApps(); + void desktopIdentityBroker + .prepareExternalSignOut(apps, { + logoutPath, + alreadyRevokedAppId: identityApp.id, + }) + .then( + () => callback({}), + (error) => { + console.error( + "[main] Failed to prepare Desktop workspace sign-out:", + error, + ); + callback({}); + }, + ); return; } - if ( !details.url.startsWith(`http://localhost:${FRAME_PORT}/api/google/`) ) { @@ -9334,6 +9364,68 @@ app.whenReady().then(async () => { } }, ); + + sess.webRequest.onCompleted( + { + urls: [ + "*://*/_agent-native/auth/logout", + "*://*/_agent-native/auth/logout-all", + ], + }, + (details) => { + const identityApp = targetAppId + ? resolveDesktopIdentityApp(targetAppId) + : null; + const logoutPath = identityApp + ? desktopWorkspaceLogoutPath(details.url, identityApp) + : null; + if ( + !identityApp || + !logoutPath || + desktopIdentityBroker?.isInternalRevocationRequest(details.url) || + details.method !== "POST" || + !desktopIdentityBroker + ) { + return; + } + void desktopIdentityBroker.completeExternalSignOut( + listDesktopIdentityCleanupApps(), + { logoutPath, alreadyRevokedAppId: identityApp.id }, + details.statusCode >= 200 && details.statusCode < 300, + ); + }, + ); + + sess.webRequest.onErrorOccurred( + { + urls: [ + "*://*/_agent-native/auth/logout", + "*://*/_agent-native/auth/logout-all", + ], + }, + (details) => { + const identityApp = targetAppId + ? resolveDesktopIdentityApp(targetAppId) + : null; + const logoutPath = identityApp + ? desktopWorkspaceLogoutPath(details.url, identityApp) + : null; + if ( + !identityApp || + !logoutPath || + desktopIdentityBroker?.isInternalRevocationRequest(details.url) || + details.method !== "POST" || + !desktopIdentityBroker + ) { + return; + } + void desktopIdentityBroker.completeExternalSignOut( + listDesktopIdentityCleanupApps(), + { logoutPath, alreadyRevokedAppId: identityApp.id }, + false, + ); + }, + ); } // Also configure session.defaultSession so the OAuth BrowserWindow (which diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 435a21b4db..d70a91b412 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -75,7 +75,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: blocked - summary: the PR is approved and mergeable, and the review findings are fixed locally; final-head CI, a fresh signed artifact, and real-app acceptance still block handoff readiness + summary: current main is integrated and the final security review is clean; final-head CI, a fresh signed artifact, and real-app acceptance still block handoff readiness verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -90,13 +90,17 @@ acceptance-state: - final-fix Desktop TypeScript passed - final-fix formatting, focused lint, and git diff checks passed - independent final technical review found no remaining actionable issues after the repeated sign-out concurrency regression was added + - current origin/main merged without conflicts; the PR diff remains the same 45-file SSO change against main + - post-integration Desktop broker and preload suite: 17 tests passed; Core identity: 18; Dispatch auth: 1; Desktop typecheck and diff checks passed + - independent security review verified authenticated server revocation, logout-all escalation during revocation and cleanup, exhaustive partial-failure cleanup, and dormant production-session cleanup for disabled, Dev-switched, missing, or edited canonical app configurations implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop - canonical-registry-only app session federation with target-cookie filtering - serialized and coalesced sign-in ceremonies with direct-login fallback - renderer-safe status and sign-in/sign-out IPC without credential material - - workspace-wide Desktop sign-out for exact canonical POST logout requests + - workspace-wide Desktop sign-out preserves exact canonical POST logout and logout-all server semantics, retains request-start credentials only for the active cleanup operation, and reports partial failure truthfully + - sign-out cleanup inventories every immutable canonical packaged production partition independently of sidebar enablement, Dev mode, or edited URLs while leaving localhost and custom origins untouched - operator docs, all localized counterparts, authentication skill, and Core changeset - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption @@ -115,6 +119,6 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-land-r8 +ledger-revision: desktop-sso-land-r9 status: active ``` From 555b91f86eec616114930c4185e000a503827468 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:04:39 -0400 Subject: [PATCH 06/31] Pin Desktop SSO canary during acceptance --- packages/desktop-app/src/main/index.ts | 6 +- .../src/main/ipc/update-policy.spec.ts | 36 ++++++ .../desktop-app/src/main/ipc/update-policy.ts | 26 ++++ .../desktop-app/src/main/ipc/updates.spec.ts | 111 ++++++++++++++++++ packages/desktop-app/src/main/ipc/updates.ts | 29 +++-- plans/desktop-sso-intent/work-ledger.md | 17 +-- 6 files changed, 204 insertions(+), 21 deletions(-) create mode 100644 packages/desktop-app/src/main/ipc/update-policy.spec.ts create mode 100644 packages/desktop-app/src/main/ipc/update-policy.ts create mode 100644 packages/desktop-app/src/main/ipc/updates.spec.ts diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 83418417e5..b809333592 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -9017,15 +9017,15 @@ app.on("web-contents-created", (_event, contents) => { // ---------- App lifecycle ---------- function buildUpdateMenuItem(): Electron.MenuItemConstructorOptions { - if (IS_DEV) { + const currentUpdateStatus = getCurrentUpdateStatus(); + + if (currentUpdateStatus.state === "unsupported") { return { label: "Check for Updates...", enabled: false, }; } - const currentUpdateStatus = getCurrentUpdateStatus(); - if (currentUpdateStatus.state === "downloaded") { return { label: currentUpdateStatus.version diff --git a/packages/desktop-app/src/main/ipc/update-policy.spec.ts b/packages/desktop-app/src/main/ipc/update-policy.spec.ts new file mode 100644 index 0000000000..1c833f115c --- /dev/null +++ b/packages/desktop-app/src/main/ipc/update-policy.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; + +import { resolveDesktopUpdateSupport } from "./update-policy.js"; + +describe("resolveDesktopUpdateSupport", () => { + it("disables updates in development", () => { + expect(resolveDesktopUpdateSupport(false, "0.1.150")).toEqual({ + supported: false, + reason: "Auto-update is disabled in development", + }); + }); + + it("disables updates for the exact Desktop SSO canary version family", () => { + expect( + resolveDesktopUpdateSupport( + true, + "0.1.150-desktop-sso-canary.30005696742", + ), + ).toEqual({ + supported: false, + reason: "Auto-update is disabled for this Desktop SSO canary build", + }); + }); + + it.each([ + "0.1.150", + "0.1.150-beta.4", + "0.1.150-desktop-sso-canary", + "0.1.150-desktop-sso-canary.not-a-run", + "0.1.150-other-canary.4", + ])("preserves normal updater behavior for %s", (version) => { + expect(resolveDesktopUpdateSupport(true, version)).toEqual({ + supported: true, + }); + }); +}); diff --git a/packages/desktop-app/src/main/ipc/update-policy.ts b/packages/desktop-app/src/main/ipc/update-policy.ts new file mode 100644 index 0000000000..2b77965ad7 --- /dev/null +++ b/packages/desktop-app/src/main/ipc/update-policy.ts @@ -0,0 +1,26 @@ +const DESKTOP_SSO_CANARY_VERSION = /-desktop-sso-canary\.\d+$/; + +export type DesktopUpdateSupport = + | { supported: true } + | { supported: false; reason: string }; + +export function resolveDesktopUpdateSupport( + isPackaged: boolean, + version: string, +): DesktopUpdateSupport { + if (!isPackaged) { + return { + supported: false, + reason: "Auto-update is disabled in development", + }; + } + + if (DESKTOP_SSO_CANARY_VERSION.test(version)) { + return { + supported: false, + reason: "Auto-update is disabled for this Desktop SSO canary build", + }; + } + + return { supported: true }; +} diff --git a/packages/desktop-app/src/main/ipc/updates.spec.ts b/packages/desktop-app/src/main/ipc/updates.spec.ts new file mode 100644 index 0000000000..3b8d83549d --- /dev/null +++ b/packages/desktop-app/src/main/ipc/updates.spec.ts @@ -0,0 +1,111 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => { + const ipcHandlers = new Map unknown>(); + return { + ipcHandlers, + app: { + isPackaged: true, + getVersion: vi.fn(), + whenReady: vi.fn(() => Promise.resolve()), + on: vi.fn(), + }, + autoUpdater: { + setFeedURL: vi.fn(), + checkForUpdates: vi.fn(() => Promise.resolve()), + downloadUpdate: vi.fn(() => Promise.resolve()), + quitAndInstall: vi.fn(), + on: vi.fn(), + autoDownload: false, + autoInstallOnAppQuit: false, + }, + }; +}); + +vi.mock("electron", () => ({ + app: mocks.app, + BrowserWindow: { getAllWindows: vi.fn(() => []) }, + ipcMain: { + handle: vi.fn( + (channel: string, handler: (...args: unknown[]) => unknown) => { + mocks.ipcHandlers.set(channel, handler); + }, + ), + }, + Notification: Object.assign( + vi.fn(() => ({ on: vi.fn(), show: vi.fn() })), + { isSupported: vi.fn(() => false) }, + ), +})); + +vi.mock("electron-updater", () => ({ autoUpdater: mocks.autoUpdater })); + +describe("Desktop updater registration", () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + mocks.ipcHandlers.clear(); + mocks.app.isPackaged = true; + mocks.autoUpdater.autoDownload = false; + mocks.autoUpdater.autoInstallOnAppQuit = false; + }); + + it("gives a Desktop SSO canary no updater network, download, or install capability", async () => { + mocks.app.getVersion.mockReturnValue("0.1.150-desktop-sso-canary.4"); + const intervalSpy = vi.spyOn(globalThis, "setInterval"); + const { getCurrentUpdateStatus, registerUpdatesIpc } = + await import("./updates.js"); + + registerUpdatesIpc({ + refreshApplicationMenu: vi.fn(), + focusMainWindow: vi.fn(), + }); + + expect(getCurrentUpdateStatus()).toEqual({ + state: "unsupported", + reason: "Auto-update is disabled for this Desktop SSO canary build", + }); + expect(mocks.autoUpdater.setFeedURL).not.toHaveBeenCalled(); + expect(mocks.autoUpdater.on).not.toHaveBeenCalled(); + expect(mocks.autoUpdater.checkForUpdates).not.toHaveBeenCalled(); + expect(intervalSpy).not.toHaveBeenCalled(); + + await mocks.ipcHandlers.get("update:check")?.(); + await mocks.ipcHandlers.get("update:download")?.(); + mocks.ipcHandlers.get("update:install")?.(); + + expect(mocks.autoUpdater.checkForUpdates).not.toHaveBeenCalled(); + expect(mocks.autoUpdater.downloadUpdate).not.toHaveBeenCalled(); + expect(mocks.autoUpdater.quitAndInstall).not.toHaveBeenCalled(); + intervalSpy.mockRestore(); + }); + + it.each(["0.1.150", "0.1.150-beta.4"])( + "preserves updater setup for %s", + async (version) => { + mocks.app.getVersion.mockReturnValue(version); + const intervalSpy = vi + .spyOn(globalThis, "setInterval") + .mockReturnValue({} as NodeJS.Timeout); + const { registerUpdatesIpc } = await import("./updates.js"); + + registerUpdatesIpc({ + refreshApplicationMenu: vi.fn(), + focusMainWindow: vi.fn(), + }); + await vi.waitFor(() => { + expect(mocks.autoUpdater.checkForUpdates).toHaveBeenCalledOnce(); + }); + + expect(mocks.autoUpdater.setFeedURL).toHaveBeenCalledWith({ + provider: "generic", + url: "https://agent-native.com/api/desktop-updates", + }); + expect(mocks.autoUpdater.on).toHaveBeenCalled(); + expect(mocks.autoUpdater.autoDownload).toBe(true); + expect(mocks.autoUpdater.autoInstallOnAppQuit).toBe(true); + expect(intervalSpy).toHaveBeenCalled(); + intervalSpy.mockRestore(); + }, + ); +}); diff --git a/packages/desktop-app/src/main/ipc/updates.ts b/packages/desktop-app/src/main/ipc/updates.ts index 263efd67a4..a808279a7a 100644 --- a/packages/desktop-app/src/main/ipc/updates.ts +++ b/packages/desktop-app/src/main/ipc/updates.ts @@ -7,14 +7,21 @@ // quitAndInstall from a sidebar pill / restart prompt. The app also // installs queued updates automatically on quit. // -// In dev, autoUpdater is unsupported (no app signature, no dev-app-update.yml), -// so we report an "unsupported" status and skip all autoUpdater calls. +// In dev, autoUpdater is unsupported (no app signature, no dev-app-update.yml). +// The signed Desktop SSO canary is also pinned to its exact artifact so the +// stable feed cannot replace it during acceptance. Both report "unsupported" +// and skip every autoUpdater call. import { IPC, type UpdateStatus } from "@shared/ipc-channels"; import { app, BrowserWindow, ipcMain, Notification } from "electron"; import { autoUpdater } from "electron-updater"; -const IS_DEV = !app.isPackaged; +import { resolveDesktopUpdateSupport } from "./update-policy.js"; + +const UPDATE_SUPPORT = resolveDesktopUpdateSupport( + app.isPackaged, + app.getVersion(), +); const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; const UPDATE_FOCUS_CHECK_MIN_INTERVAL_MS = 15 * 60 * 1000; @@ -25,9 +32,9 @@ const DESKTOP_UPDATE_FEED_URL = ( DEFAULT_DESKTOP_UPDATE_FEED_URL ).replace(/\/+$/, ""); -let currentUpdateStatus: UpdateStatus = IS_DEV - ? { state: "unsupported", reason: "Auto-update is disabled in development" } - : { state: "idle" }; +let currentUpdateStatus: UpdateStatus = UPDATE_SUPPORT.supported + ? { state: "idle" } + : { state: "unsupported", reason: UPDATE_SUPPORT.reason }; let updateCheckInFlight: Promise | null = null; let lastUpdateCheckStartedAt = 0; let notifiedUpdateVersion: string | null = null; @@ -66,7 +73,7 @@ function broadcastUpdateStatus(status: UpdateStatus) { /** Triggers (or awaits an in-flight) update check. Exported for the app menu's "Check for Updates" item. */ export async function checkForAppUpdates(): Promise { - if (IS_DEV) return currentUpdateStatus; + if (!UPDATE_SUPPORT.supported) return currentUpdateStatus; if (currentUpdateStatus.state === "downloaded") return currentUpdateStatus; if (!updateCheckInFlight) { @@ -89,7 +96,7 @@ export async function checkForAppUpdates(): Promise { } function maybeCheckForAppUpdates() { - if (IS_DEV) return; + if (!UPDATE_SUPPORT.supported) return; if (currentUpdateStatus.state === "downloaded") return; if ( updateCheckInFlight || @@ -122,7 +129,7 @@ function showUpdateReadyNotification(version: string) { export function registerUpdatesIpc(ipcDeps: UpdatesIpcDeps): void { deps = ipcDeps; - if (!IS_DEV) { + if (UPDATE_SUPPORT.supported) { // The GitHub provider reads the repository-wide latest release feed, which // also contains npm package releases and Clips desktop releases. Use the // Agent Native feed that filters the shared repo down to desktop assets. @@ -199,7 +206,7 @@ export function registerUpdatesIpc(ipcDeps: UpdatesIpcDeps): void { }); ipcMain.handle(IPC.UPDATE_DOWNLOAD, async (): Promise => { - if (IS_DEV) return currentUpdateStatus; + if (!UPDATE_SUPPORT.supported) return currentUpdateStatus; try { await autoUpdater.downloadUpdate(); } catch (err) { @@ -212,7 +219,7 @@ export function registerUpdatesIpc(ipcDeps: UpdatesIpcDeps): void { }); ipcMain.handle(IPC.UPDATE_INSTALL, () => { - if (IS_DEV) return; + if (!UPDATE_SUPPORT.supported) return; // isSilent=false so any installer UI shows; isForceRunAfter=true so the // app relaunches after the update completes. autoUpdater.quitAndInstall(false, true); diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index d70a91b412..8fe2c8547b 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -1,8 +1,8 @@ # Desktop workspace SSO work ledger ```yaml -stage: land -authority-source: "Alice: $land but don't merge yet." +stage: work +authority-source: "Alice: $work after the desktop SSO Land no-go packet." authorized-scope: repositories: - BuilderIO/agent-native @@ -75,7 +75,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: blocked - summary: current main is integrated and the final security review is clean; final-head CI, a fresh signed artifact, and real-app acceptance still block handoff readiness + summary: exact-head code, CI, signing, review, and rollback are proven; updater-safe exact-artifact QA plus authenticated real-app acceptance still block handoff readiness verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -93,6 +93,9 @@ acceptance-state: - current origin/main merged without conflicts; the PR diff remains the same 45-file SSO change against main - post-integration Desktop broker and preload suite: 17 tests passed; Core identity: 18; Dispatch auth: 1; Desktop typecheck and diff checks passed - independent security review verified authenticated server revocation, logout-all escalation during revocation and cleanup, exhaustive partial-failure cleanup, and dormant production-session cleanup for disabled, Dev-switched, missing, or edited canonical app configurations + - exact-head required CI and the signed/notarized macOS canary workflow passed + - the short locked production canary proved canonical Mail to Dispatch routing, callback/state construction, hostile-callback rejection, and safe reverse-order rollback + - the exact signed canary launched and reached production Dispatch, but its browser had no authenticated Dispatch identity implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -105,9 +108,9 @@ acceptance-state: - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption blockers: - - final-head CI must pass, including a clean Fast Tests run under the repository's supported Node 22 environment - - the material review fixes require a new signed artifact and independent real-app acceptance across the frozen restart, sign-out, account-switch, isolation, and hostile-flow matrix - last-land-packet: null + - the branch canary must not offer, download, or install a stable Desktop update while exact-artifact acceptance is running + - a safe authenticated Dispatch test identity is required to complete same-account Mail data, restart, sign-out, account-switch, isolation, and hostile-flow acceptance + last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: allowed: - branch-scoped GitHub Actions macOS canary build with publish disabled @@ -119,6 +122,6 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-land-r9 +ledger-revision: desktop-sso-work-r10 status: active ``` From d2a3919cd1076325c45c679aba2a18c6e252c6a3 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:08:28 -0400 Subject: [PATCH 07/31] Correct Desktop SSO work evidence state --- plans/desktop-sso-intent/work-ledger.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 8fe2c8547b..922c877e18 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -75,7 +75,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: blocked - summary: exact-head code, CI, signing, review, and rollback are proven; updater-safe exact-artifact QA plus authenticated real-app acceptance still block handoff readiness + summary: the updater guard is locally proven and independently reviewed; current-head CI, a fresh signed artifact, updater-safe exact-artifact QA, and authenticated real-app acceptance still block handoff readiness verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -90,12 +90,14 @@ acceptance-state: - final-fix Desktop TypeScript passed - final-fix formatting, focused lint, and git diff checks passed - independent final technical review found no remaining actionable issues after the repeated sign-out concurrency regression was added - - current origin/main merged without conflicts; the PR diff remains the same 45-file SSO change against main + - current origin/main merged without conflicts; the SSO implementation remains based on current main - post-integration Desktop broker and preload suite: 17 tests passed; Core identity: 18; Dispatch auth: 1; Desktop typecheck and diff checks passed - independent security review verified authenticated server revocation, logout-all escalation during revocation and cleanup, exhaustive partial-failure cleanup, and dormant production-session cleanup for disabled, Dev-switched, missing, or edited canonical app configurations - - exact-head required CI and the signed/notarized macOS canary workflow passed + - prior head 8d33ffffe9e8b0b3acc78e725736a96f97dae7e5 passed required CI and the signed/notarized macOS canary workflow - the short locked production canary proved canonical Mail to Dispatch routing, callback/state construction, hostile-callback rejection, and safe reverse-order rollback - the exact signed canary launched and reached production Dispatch, but its browser had no authenticated Dispatch identity + - current updater guard focused suite: 10 tests passed; Desktop identity, preload, and updater suite: 27 tests passed; Desktop typecheck and diff checks passed + - independent incremental review found no updater-code defects and verified the exact canary version family has no feed, check, download, install, listener, focus, ready-callback, or timer capability while stable and unrelated prerelease builds retain normal updater behavior implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -108,6 +110,7 @@ acceptance-state: - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption blockers: + - current-head required CI and a fresh signed/notarized canary artifact must pass - the branch canary must not offer, download, or install a stable Desktop update while exact-artifact acceptance is running - a safe authenticated Dispatch test identity is required to complete same-account Mail data, restart, sign-out, account-switch, isolation, and hostile-flow acceptance last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 @@ -122,6 +125,6 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-work-r10 +ledger-revision: desktop-sso-work-r11 status: active ``` From 3f623cf2585dc1a504a5b3b2d1314fe41fa25b22 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:56:53 -0400 Subject: [PATCH 08/31] Stabilize A2A deadline test under load --- packages/core/src/a2a/client.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/a2a/client.spec.ts b/packages/core/src/a2a/client.spec.ts index 014068b1b6..702368c0b2 100644 --- a/packages/core/src/a2a/client.spec.ts +++ b/packages/core/src/a2a/client.spec.ts @@ -197,7 +197,7 @@ describe("A2AClient", () => { JSON.parse(String(init.body)).method === "tasks/get", )?.[1]?.signal, ).toBeInstanceOf(AbortSignal); - }); + }, 15_000); it("recovers after one task-status request exceeds the per-request timeout", async () => { vi.useFakeTimers(); From 0ab3d3bfff85b4f45b0a6da0ac7d319c78faeb2d Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:01:23 -0400 Subject: [PATCH 09/31] Use main A2A deadline stabilization --- packages/core/src/a2a/client.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/a2a/client.spec.ts b/packages/core/src/a2a/client.spec.ts index 702368c0b2..014068b1b6 100644 --- a/packages/core/src/a2a/client.spec.ts +++ b/packages/core/src/a2a/client.spec.ts @@ -197,7 +197,7 @@ describe("A2AClient", () => { JSON.parse(String(init.body)).method === "tasks/get", )?.[1]?.signal, ).toBeInstanceOf(AbortSignal); - }, 15_000); + }); it("recovers after one task-status request exceeds the per-request timeout", async () => { vi.useFakeTimers(); From 3d380f5db0d5ae93b310b3665c165d2a3716f1fd Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:38:53 -0400 Subject: [PATCH 10/31] Refresh Desktop SSO integration evidence --- plans/desktop-sso-intent/work-ledger.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 922c877e18..de391bfdee 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -75,7 +75,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: blocked - summary: the updater guard is locally proven and independently reviewed; current-head CI, a fresh signed artifact, updater-safe exact-artifact QA, and authenticated real-app acceptance still block handoff readiness + summary: current main is integrated and the conflict-focused identity/updater suites pass; exact-head CI, a fresh signed artifact, updater-safe exact-artifact QA, and authenticated real-app acceptance still block handoff readiness verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -90,14 +90,16 @@ acceptance-state: - final-fix Desktop TypeScript passed - final-fix formatting, focused lint, and git diff checks passed - independent final technical review found no remaining actionable issues after the repeated sign-out concurrency regression was added - - current origin/main merged without conflicts; the SSO implementation remains based on current main + - current origin/main ce426feef1ebeb370fca581291c9391339a757ed integrated in merge commit 7d4cbf499; updater and Dispatch identity conflicts were resolved by retaining both current-main recovery/sign-in behavior and the frozen SSO safety boundaries - post-integration Desktop broker and preload suite: 17 tests passed; Core identity: 18; Dispatch auth: 1; Desktop typecheck and diff checks passed - independent security review verified authenticated server revocation, logout-all escalation during revocation and cleanup, exhaustive partial-failure cleanup, and dormant production-session cleanup for disabled, Dev-switched, missing, or edited canonical app configurations - prior head 8d33ffffe9e8b0b3acc78e725736a96f97dae7e5 passed required CI and the signed/notarized macOS canary workflow - the short locked production canary proved canonical Mail to Dispatch routing, callback/state construction, hostile-callback rejection, and safe reverse-order rollback - the exact signed canary launched and reached production Dispatch, but its browser had no authenticated Dispatch identity - - current updater guard focused suite: 10 tests passed; Desktop identity, preload, and updater suite: 27 tests passed; Desktop typecheck and diff checks passed - - independent incremental review found no updater-code defects and verified the exact canary version family has no feed, check, download, install, listener, focus, ready-callback, or timer capability while stable and unrelated prerelease builds retain normal updater behavior + - pre-integration updater guard focused suite: 10 tests passed; Desktop identity, preload, and updater suite: 27 tests passed; Desktop typecheck and diff checks passed + - pre-integration independent review found no updater-code defects and verified the exact canary version family has no feed, check, download, install, listener, focus, ready-callback, or timer capability while stable and unrelated prerelease builds retain normal updater behavior + - post-integration focused verification on 7d4cbf499: Desktop identity, updater policy/runtime, and preload 29 tests passed; Core identity 27; Dispatch primary-auth forwarding 1; Dispatch identity library 24 + - post-integration conflict review verified every updater touchpoint retains the exact canary eligibility guard while current-main delayed native staging and manual-result notifications remain intact; Dispatch retains the sign-in journey while primary auth remains the sole initializer implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -110,7 +112,8 @@ acceptance-state: - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption blockers: - - current-head required CI and a fresh signed/notarized canary artifact must pass + - exact-head required CI must pass after the current-main integration; the Framework compute host was unavailable, so heavy package and full-repository gates remain assigned to GitHub CI + - a fresh signed/notarized canary artifact must be produced because the prior exact-head artifact expired on 2026-07-26 - the branch canary must not offer, download, or install a stable Desktop update while exact-artifact acceptance is running - a safe authenticated Dispatch test identity is required to complete same-account Mail data, restart, sign-out, account-switch, isolation, and hostile-flow acceptance last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 @@ -125,6 +128,6 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-work-r11 +ledger-revision: desktop-sso-work-r12 status: active ``` From 0dacfbe3343425f64b4313321a0589bd47e4b9c0 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:57:11 -0400 Subject: [PATCH 11/31] Record exact-head canary readiness --- plans/desktop-sso-intent/work-ledger.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index de391bfdee..9cbb105e7b 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -75,7 +75,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: blocked - summary: current main is integrated and the conflict-focused identity/updater suites pass; exact-head CI, a fresh signed artifact, updater-safe exact-artifact QA, and authenticated real-app acceptance still block handoff readiness + summary: current main is integrated, exact-head CI and signed artifact trust are green, and exact-head Mail and Dispatch candidates are ready; the short canonical-origin canary, updater-safe installed-app QA, and authenticated real-app acceptance still block handoff readiness verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -100,6 +100,13 @@ acceptance-state: - pre-integration independent review found no updater-code defects and verified the exact canary version family has no feed, check, download, install, listener, focus, ready-callback, or timer capability while stable and unrelated prerelease builds retain normal updater behavior - post-integration focused verification on 7d4cbf499: Desktop identity, updater policy/runtime, and preload 29 tests passed; Core identity 27; Dispatch primary-auth forwarding 1; Dispatch identity library 24 - post-integration conflict review verified every updater touchpoint retains the exact canary eligibility guard while current-main delayed native staging and manual-result notifications remain intact; Dispatch retains the sign-in journey while primary auth remains the sole initializer + - exact-head required CI run 30480891661 passed on 3d380f5db0d5ae93b310b3665c165d2a3716f1fd, including build, typecheck, security guards, Core integration, scaffold, SSR smoke, and every fast-test lane + - exact-head signed canary run 30480891545 passed signing, notarization, provenance, trust verification, and short-lived artifact upload for desktop-sso-canary-3d380f5db0d5ae93b310b3665c165d2a3716f1fd + - downloaded canary 0.1.150-desktop-sso-canary.9 matched all four manifest SHA-256 values and the arm64 app passed strict deep codesign, stapler, Gatekeeper, bundle id, version, signer, and architecture checks locally + - exact-head canary .9 is installed side-by-side at /Applications/Agent Native SSO Canary.app; the prior .2 canary is preserved recoverably at /Applications/Agent Native SSO Canary.previous.app and the stable Desktop app is untouched + - immutable exact-head Netlify candidates 6a6a48cbd8ca3400088ba95f (Mail) and 6a6a48cbc9c76200085e212c (Dispatch) are ready and unpublished + - candidate preflight proved Mail constructs the canonical Dispatch authorize route, Dispatch returns the logged-out sign-in continuation for the canonical Mail callback, and Dispatch rejects a hostile callback with 400 before session work + - fresh production rollback targets are Mail 6a6a3a4ee65f5f0008ca3fc5 and Dispatch 6a6a3a4e9b07310008196a34, both current-main ce426feef1ebeb370fca581291c9391339a757ed implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -112,9 +119,8 @@ acceptance-state: - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption blockers: - - exact-head required CI must pass after the current-main integration; the Framework compute host was unavailable, so heavy package and full-repository gates remain assigned to GitHub CI - - a fresh signed/notarized canary artifact must be produced because the prior exact-head artifact expired on 2026-07-26 - - the branch canary must not offer, download, or install a stable Desktop update while exact-artifact acceptance is running + - authenticated Netlify publication and rollback control must be exercised for the short canonical-origin canary; the current tool surface has no Netlify connector, CLI session, or callable in-app browser control despite the user's authenticated browser session + - the installed exact-head branch canary must prove it does not offer, download, or install a stable Desktop update during acceptance - a safe authenticated Dispatch test identity is required to complete same-account Mail data, restart, sign-out, account-switch, isolation, and hostile-flow acceptance last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: @@ -128,6 +134,6 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-work-r12 +ledger-revision: desktop-sso-work-r13 status: active ``` From 49fd3774d13228bb49467837153059e31b3bcacd Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:50:27 -0400 Subject: [PATCH 12/31] fix(desktop): forward webview navigation URLs --- packages/desktop-app/src/main/index.ts | 13 ++-------- .../src/main/webview-navigation.spec.ts | 25 +++++++++++++++++++ .../src/main/webview-navigation.ts | 23 +++++++++++++++++ 3 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 packages/desktop-app/src/main/webview-navigation.spec.ts create mode 100644 packages/desktop-app/src/main/webview-navigation.ts diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 6c5b67d18c..bed3400444 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -202,6 +202,7 @@ import { installSentryWebContentsInstrumentation, setSentryWebContentsMetadata, } from "./sentry"; +import { installWebviewNavigationListeners } from "./webview-navigation"; initializeDesktopSentry(); initializeDesktopLogger(); @@ -8875,20 +8876,10 @@ function installWebviewOAuthNavigationHandler(contents: Electron.WebContents) { } }; - contents.on("will-frame-navigate", (event) => { - if (event.isMainFrame) return; - handleNavigation(event, event.url, { isMainFrame: false }); - }); - // Belt-and-suspenders for existing deployed app bundles that may still // fall back to assigning window.location when Electron reports a manually // handled popup as null. Keep Builder/Google OAuth out of the app webview. - contents.on("will-navigate", (event) => { - handleNavigation(event, event.url, { isMainFrame: true }); - }); - contents.on("will-redirect", (event, url, _isInPlace, isMainFrame) => { - handleNavigation(event, url, { isMainFrame }); - }); + installWebviewNavigationListeners(contents, handleNavigation); } // ---------- Webview popup handling ---------- diff --git a/packages/desktop-app/src/main/webview-navigation.spec.ts b/packages/desktop-app/src/main/webview-navigation.spec.ts new file mode 100644 index 0000000000..74bad6d746 --- /dev/null +++ b/packages/desktop-app/src/main/webview-navigation.spec.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +import { installWebviewNavigationListeners } from "./webview-navigation"; + +describe("webview navigation listeners", () => { + it("forwards the legacy will-navigate URL when the event has no URL", () => { + const listeners = new Map void>(); + const contents = { + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + } as unknown as Electron.WebContents; + const handleNavigation = vi.fn(); + installWebviewNavigationListeners(contents, handleNavigation); + + const event = { preventDefault: vi.fn() }; + const url = + "https://mail.agent-native.com/_agent-native/sign-in?return=%2Finbox"; + listeners.get("will-navigate")?.(event as never, url as never); + + expect(handleNavigation).toHaveBeenCalledWith(event, url, { + isMainFrame: true, + }); + }); +}); diff --git a/packages/desktop-app/src/main/webview-navigation.ts b/packages/desktop-app/src/main/webview-navigation.ts new file mode 100644 index 0000000000..ca63dc6cf9 --- /dev/null +++ b/packages/desktop-app/src/main/webview-navigation.ts @@ -0,0 +1,23 @@ +export type WebviewNavigationHandler = ( + event: Electron.Event, + url: string, + options: { isMainFrame: boolean }, +) => void; + +export function installWebviewNavigationListeners( + contents: Electron.WebContents, + handleNavigation: WebviewNavigationHandler, +) { + contents.on("will-frame-navigate", (event) => { + if (event.isMainFrame) return; + handleNavigation(event, event.url, { isMainFrame: false }); + }); + + contents.on("will-navigate", (event, url) => { + handleNavigation(event, url || event.url, { isMainFrame: true }); + }); + + contents.on("will-redirect", (event, url, _isInPlace, isMainFrame) => { + handleNavigation(event, url, { isMainFrame }); + }); +} From 5d57687b13b0d3572bbd6a10a525ed43cba28139 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:35:58 -0400 Subject: [PATCH 13/31] fix(desktop): handle main-frame webview navigation --- packages/desktop-app/src/main/index.ts | 3 --- .../src/main/webview-navigation.spec.ts | 22 +++++++++++++++++++ .../src/main/webview-navigation.ts | 4 ++-- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index bed3400444..84bc1753b1 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -8876,9 +8876,6 @@ function installWebviewOAuthNavigationHandler(contents: Electron.WebContents) { } }; - // Belt-and-suspenders for existing deployed app bundles that may still - // fall back to assigning window.location when Electron reports a manually - // handled popup as null. Keep Builder/Google OAuth out of the app webview. installWebviewNavigationListeners(contents, handleNavigation); } diff --git a/packages/desktop-app/src/main/webview-navigation.spec.ts b/packages/desktop-app/src/main/webview-navigation.spec.ts index 74bad6d746..c0a2d1b6bf 100644 --- a/packages/desktop-app/src/main/webview-navigation.spec.ts +++ b/packages/desktop-app/src/main/webview-navigation.spec.ts @@ -3,6 +3,28 @@ import { describe, expect, it, vi } from "vitest"; import { installWebviewNavigationListeners } from "./webview-navigation"; describe("webview navigation listeners", () => { + it("forwards main-frame navigation from will-frame-navigate", () => { + const listeners = new Map void>(); + const contents = { + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + } as unknown as Electron.WebContents; + const handleNavigation = vi.fn(); + installWebviewNavigationListeners(contents, handleNavigation); + + const event = { + isMainFrame: true, + preventDefault: vi.fn(), + url: "https://mail.agent-native.com/_agent-native/sign-in", + }; + listeners.get("will-frame-navigate")?.(event as never); + + expect(handleNavigation).toHaveBeenCalledWith(event, event.url, { + isMainFrame: true, + }); + }); + it("forwards the legacy will-navigate URL when the event has no URL", () => { const listeners = new Map void>(); const contents = { diff --git a/packages/desktop-app/src/main/webview-navigation.ts b/packages/desktop-app/src/main/webview-navigation.ts index ca63dc6cf9..69d4f28cee 100644 --- a/packages/desktop-app/src/main/webview-navigation.ts +++ b/packages/desktop-app/src/main/webview-navigation.ts @@ -9,10 +9,10 @@ export function installWebviewNavigationListeners( handleNavigation: WebviewNavigationHandler, ) { contents.on("will-frame-navigate", (event) => { - if (event.isMainFrame) return; - handleNavigation(event, event.url, { isMainFrame: false }); + handleNavigation(event, event.url, { isMainFrame: event.isMainFrame }); }); + // Retain the legacy positional URL fallback for webview navigation events. contents.on("will-navigate", (event, url) => { handleNavigation(event, url || event.url, { isMainFrame: true }); }); From d19c92f1d1ebe3ab336506e9f27e6efa9125da25 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:08:53 -0400 Subject: [PATCH 14/31] fix(desktop): resolve identity redirects with net request --- .../src/main/desktop-identity.spec.ts | 163 ++++++++++++++++-- .../desktop-app/src/main/desktop-identity.ts | 66 +++++-- packages/desktop-app/src/main/index.ts | 45 +++++ plans/desktop-sso-intent/work-ledger.md | 13 +- 4 files changed, 260 insertions(+), 27 deletions(-) diff --git a/packages/desktop-app/src/main/desktop-identity.spec.ts b/packages/desktop-app/src/main/desktop-identity.spec.ts index 3f4d1081dd..7ebd3ffcea 100644 --- a/packages/desktop-app/src/main/desktop-identity.spec.ts +++ b/packages/desktop-app/src/main/desktop-identity.spec.ts @@ -4,6 +4,7 @@ import { DESKTOP_IDENTITY_COMPLETE_PATH, DesktopIdentityBroker, desktopWorkspaceLogoutPath, + isDesktopIdentityAuthorizeNavigation, isDesktopIdentityCompletion, isDesktopIdentityConfiguredAppEligible, isDesktopSignInNavigation, @@ -56,6 +57,29 @@ function appFixture(): DesktopIdentityApp { }; } +function authorityFixture(): DesktopIdentityApp { + return { + ...appFixture(), + id: "dispatch", + origin: "https://dispatch.agent-native.com", + identityAuthority: true, + }; +} + +function authorizeUrl( + authority: DesktopIdentityApp, + target: DesktopIdentityApp, +): string { + const result = new URL("/_agent-native/identity/authorize", authority.origin); + result.searchParams.set("app", target.id); + result.searchParams.set( + "redirect_uri", + new URL("/_agent-native/identity/callback", target.origin).toString(), + ); + result.searchParams.set("state", "state_123"); + return result.toString(); +} + function deferred() { let resolve!: (value: T) => void; const promise = new Promise((resolvePromise) => { @@ -131,6 +155,33 @@ describe("Desktop identity navigation boundaries", () => { ).toBe(false); }); + it("accepts authorize redirects only for the exact authority and callback", () => { + const app = appFixture(); + const authority = authorityFixture(); + expect( + isDesktopIdentityAuthorizeNavigation( + authorizeUrl(authority, app), + authority, + app, + ), + ).toBe(true); + expect( + isDesktopIdentityAuthorizeNavigation( + authorizeUrl(authority, { ...app, id: "calendar" }), + authority, + app, + ), + ).toBe(false); + const hostile = new URL(authorizeUrl(authority, app)); + hostile.searchParams.set( + "redirect_uri", + "https://evil.example/_agent-native/identity/callback", + ); + expect( + isDesktopIdentityAuthorizeNavigation(hostile.toString(), authority, app), + ).toBe(false); + }); + it("recognizes workspace logout only on the canonical app origin", () => { expect( isDesktopWorkspaceLogoutRequest( @@ -172,6 +223,103 @@ describe("Desktop identity navigation boundaries", () => { }); describe("DesktopIdentityBroker", () => { + it("loads the validated identity authority redirect", async () => { + const app = appFixture(); + const authority = authorityFixture(); + const resolvedUrl = authorizeUrl(authority, app); + const resolveLoginRedirect = vi.fn(async () => resolvedUrl); + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents: { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }, + loadURL: vi.fn(async () => {}), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect, + resolveApp: (id) => + id === app.id ? app : id === authority.id ? authority : null, + createWindow: () => identityWindow as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => + expect(identityWindow.loadURL).toHaveBeenCalledWith(resolvedUrl), + ); + expect(resolveLoginRedirect).toHaveBeenCalledWith( + expect.stringContaining("/_agent-native/identity/login"), + expect.objectContaining({ cookies: expect.anything() }), + ); + closedListener?.(); + await expect(ceremony).resolves.toBe(false); + }); + + it("sanitizes identity preflight failures before logging", async () => { + const app = appFixture(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect: vi.fn(async () => { + throw new Error("redirect failed?state=secret&token=secret"); + }), + resolveApp: (id) => (id === app.id ? app : null), + createWindow: vi.fn() as never, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + await expect(broker.ensureAppSession(app.id)).resolves.toBe(false); + expect(warn).toHaveBeenCalledWith( + "[desktop-identity] identity preflight failed", + ); + expect(JSON.stringify(warn.mock.calls)).not.toContain("state=secret"); + expect(JSON.stringify(warn.mock.calls)).not.toContain("token=secret"); + expect(reloadApp).toHaveBeenCalledWith(app); + warn.mockRestore(); + }); + + it("rejects a redirect outside the identity authority", async () => { + const app = appFixture(); + const authority = authorityFixture(); + const createWindow = vi.fn(); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect: vi.fn( + async () => "https://evil.example/_agent-native/identity/authorize", + ), + resolveApp: (id) => + id === app.id ? app : id === authority.id ? authority : null, + createWindow, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + await expect(broker.ensureAppSession(app.id)).resolves.toBe(false); + expect(createWindow).not.toHaveBeenCalled(); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("failed"); + }); + it("coalesces duplicate requests and copies only the target cookie", async () => { const app = appFixture(); const identityCookies = cookieStore([ @@ -546,16 +694,16 @@ describe("DesktopIdentityBroker", () => { it("invalidates a queued ceremony before it can open a window", async () => { const app = appFixture(); - const fetchResponse = deferred(); - const fetch = vi.fn(() => fetchResponse.promise); + const redirectResponse = deferred(); + const resolveLoginRedirect = vi.fn(() => redirectResponse.promise); const createWindow = vi.fn(); const reloadApp = vi.fn(); const broker = new DesktopIdentityBroker({ identitySession: { cookies: cookieStore(), clearStorageData: vi.fn(async () => {}), - fetch, } as unknown as Electron.Session, + resolveLoginRedirect, resolveApp: (id) => (id === app.id ? app : null), createWindow, reloadApp, @@ -563,14 +711,9 @@ describe("DesktopIdentityBroker", () => { }); const ceremony = broker.ensureAppSession(app.id); - await vi.waitFor(() => expect(fetch).toHaveBeenCalled()); + await vi.waitFor(() => expect(resolveLoginRedirect).toHaveBeenCalled()); await broker.signOut([app]); - fetchResponse.resolve( - new Response(null, { - status: 302, - headers: { location: "https://dispatch.agent-native.com/sign-in" }, - }), - ); + redirectResponse.resolve("https://dispatch.agent-native.com/sign-in"); await expect(ceremony).resolves.toBe(false); expect(createWindow).not.toHaveBeenCalled(); diff --git a/packages/desktop-app/src/main/desktop-identity.ts b/packages/desktop-app/src/main/desktop-identity.ts index 93432e3d47..ac1f759dd4 100644 --- a/packages/desktop-app/src/main/desktop-identity.ts +++ b/packages/desktop-app/src/main/desktop-identity.ts @@ -14,6 +14,8 @@ export const DESKTOP_IDENTITY_COMPLETE_PATH = const DESKTOP_SIGN_IN_PATH = "/_agent-native/sign-in"; const DESKTOP_IDENTITY_LOGIN_PATH = "/_agent-native/identity/login"; +const DESKTOP_IDENTITY_AUTHORIZE_PATH = "/_agent-native/identity/authorize"; +const DESKTOP_IDENTITY_CALLBACK_PATH = "/_agent-native/identity/callback"; const DESKTOP_LOGOUT_PATH = "/_agent-native/auth/logout"; const DESKTOP_LOGOUT_ALL_PATH = "/_agent-native/auth/logout-all"; const DEFAULT_CEREMONY_TIMEOUT_MS = 5 * 60 * 1000; @@ -87,6 +89,10 @@ interface DesktopIdentityWindow { export interface DesktopIdentityBrokerOptions { identitySession: Session; + resolveLoginRedirect?: ( + url: string, + identitySession: Session, + ) => Promise; resolveApp: (appId: string) => DesktopIdentityApp | null; createWindow: ( options: BrowserWindowConstructorOptions, @@ -152,6 +158,28 @@ export function isDesktopIdentityCompletion( } } +export function isDesktopIdentityAuthorizeNavigation( + navigationUrl: string, + authorityApp: DesktopIdentityApp, + targetApp: DesktopIdentityApp, +): boolean { + try { + const parsed = new URL(navigationUrl); + const callback = new URL(parsed.searchParams.get("redirect_uri") ?? ""); + return ( + authorityApp.identityAuthority === true && + parsed.origin === authorityApp.origin && + parsed.pathname === DESKTOP_IDENTITY_AUTHORIZE_PATH && + parsed.searchParams.get("app") === targetApp.id && + Boolean(parsed.searchParams.get("state")) && + callback.origin === targetApp.origin && + callback.pathname === DESKTOP_IDENTITY_CALLBACK_PATH + ); + } catch { + return false; + } +} + export class DesktopIdentityBroker { private readonly pendingByApp = new Map>(); private readonly unsupportedAppIds = new Set(); @@ -528,22 +556,34 @@ export class DesktopIdentityBroker { ); let initialUrl = loginUrl.toString(); - if (typeof this.options.identitySession.fetch === "function") { + if (this.options.resolveLoginRedirect) { + let redirectUrl: string | null; try { - const response = await this.options.identitySession.fetch(initialUrl, { - redirect: "manual", - }); - if (!this.isCeremonyCurrent(generation)) return false; - const location = response.headers.get("location"); - if (response.status < 300 || response.status >= 400 || !location) { - this.unsupportedAppIds.add(app.id); - this.setStatus("failed"); - this.options.reloadApp(app); - return false; - } - initialUrl = new URL(location, initialUrl).toString(); + redirectUrl = await this.options.resolveLoginRedirect( + initialUrl, + this.options.identitySession, + ); } catch { if (!this.isCeremonyCurrent(generation)) return false; + console.warn("[desktop-identity] identity preflight failed"); + this.unsupportedAppIds.add(app.id); + this.setStatus("failed"); + this.options.reloadApp(app); + return false; + } + if (!this.isCeremonyCurrent(generation)) return false; + if (!redirectUrl) { + this.unsupportedAppIds.add(app.id); + this.setStatus("failed"); + this.options.reloadApp(app); + return false; + } + initialUrl = redirectUrl; + const authorityApp = this.options.resolveApp("dispatch"); + if ( + !authorityApp || + !isDesktopIdentityAuthorizeNavigation(initialUrl, authorityApp, app) + ) { this.unsupportedAppIds.add(app.id); this.setStatus("failed"); this.options.reloadApp(app); diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 84bc1753b1..c6804cc369 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -112,6 +112,7 @@ import { globalShortcut, ipcMain, Menu, + net, Notification, session, shell, @@ -615,6 +616,49 @@ function listDesktopIdentityCleanupApps(): DesktopIdentityApp[] { ).filter((candidate): candidate is DesktopIdentityApp => candidate !== null); } +function resolveDesktopIdentityLoginRedirect( + requestUrl: string, + identitySession: Electron.Session, +): Promise { + return new Promise((resolve, reject) => { + const request = net.request({ + url: requestUrl, + session: identitySession, + redirect: "manual", + }); + let settled = false; + let timer: ReturnType | undefined; + const finish = (redirectUrl: string | null) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(redirectUrl); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + reject(error); + }; + + request.on("redirect", (_statusCode, _method, redirectUrl) => { + finish(redirectUrl); + request.abort(); + }); + request.on("response", (response) => { + response.on("data", () => {}); + response.on("end", () => finish(null)); + response.on("error", fail); + }); + request.on("error", fail); + timer = setTimeout(() => { + request.abort(); + fail(new Error("Identity redirect preflight timed out")); + }, 15_000); + request.end(); + }); +} + function getOAuthInjectionTarget( sourceSession: Electron.Session | undefined, sourceUrl: string | undefined, @@ -9269,6 +9313,7 @@ function configurePermissionHandlers( app.whenReady().then(async () => { desktopIdentityBroker = new DesktopIdentityBroker({ identitySession: session.fromPartition(DESKTOP_IDENTITY_PARTITION), + resolveLoginRedirect: resolveDesktopIdentityLoginRedirect, resolveApp: resolveDesktopIdentityApp, createWindow: (options) => new BrowserWindow(options), parentWindow: () => mainWindow, diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 9cbb105e7b..312ae46258 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -75,7 +75,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: blocked - summary: current main is integrated, exact-head CI and signed artifact trust are green, and exact-head Mail and Dispatch candidates are ready; the short canonical-origin canary, updater-safe installed-app QA, and authenticated real-app acceptance still block handoff readiness + summary: signed canary .12 and updater isolation passed, but native Mail activation failed before reaching Dispatch; the Electron redirect preflight repair now passes local tests and independent review, while a fresh signed canary and the full installed-app acceptance story still block handoff readiness verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -107,6 +107,11 @@ acceptance-state: - immutable exact-head Netlify candidates 6a6a48cbd8ca3400088ba95f (Mail) and 6a6a48cbc9c76200085e212c (Dispatch) are ready and unpublished - candidate preflight proved Mail constructs the canonical Dispatch authorize route, Dispatch returns the logged-out sign-in continuation for the canonical Mail callback, and Dispatch rejects a hostile callback with 400 before session work - fresh production rollback targets are Mail 6a6a3a4ee65f5f0008ca3fc5 and Dispatch 6a6a3a4e9b07310008196a34, both current-main ce426feef1ebeb370fca581291c9391339a757ed + - exact-head required CI run 30489087026 and signed canary run 30489085253 passed on 5d57687b13b0d3572bbd6a10a525ed43cba28139; canary .12 passed manifest checks, strict signing, notarization, Gatekeeper, bundle identity, version, and arm64 verification + - independent updater-isolation QA on canary .12 passed a four-minute observation and real quit/relaunch cycle with no updater activity + - two fresh post-publication profiles proved the identity control renders but native activation does not reach Dispatch; both short production windows were rolled back in reverse order and Mail and Dispatch were restored unlocked to ce426fe + - Electron 41.9 runtime probes proved session.fetch manual redirects throw Redirect was cancelled, followed fetches omit response.url, and net.request manual mode exposes the first redirect event + - the replacement native redirect resolver passes 19 focused identity/navigation tests, 228 full Desktop tests, Desktop typecheck, formatting, and diff checks; independent review found no redirect-trust, request-lifecycle, cancellation, cookie-isolation, or logging defect implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -119,8 +124,8 @@ acceptance-state: - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption blockers: - - authenticated Netlify publication and rollback control must be exercised for the short canonical-origin canary; the current tool surface has no Netlify connector, CLI session, or callable in-app browser control despite the user's authenticated browser session - - the installed exact-head branch canary must prove it does not offer, download, or install a stable Desktop update during acceptance + - the redirect-preflight repair needs exact-head required CI, a newly signed and notarized canary, and another updater-isolation preflight before reopening the short canonical-origin window + - the installed canary must prove native Mail activation reaches Dispatch before authenticated acceptance continues - a safe authenticated Dispatch test identity is required to complete same-account Mail data, restart, sign-out, account-switch, isolation, and hostile-flow acceptance last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: @@ -134,6 +139,6 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-work-r13 +ledger-revision: desktop-sso-work-r14 status: active ``` From 33be7c1c9d50a3d13518a2400d880320c8c1fe5e Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:35:36 -0400 Subject: [PATCH 15/31] docs: record desktop SSO canary acceptance --- plans/desktop-sso-intent/work-ledger.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 312ae46258..9ef1c6b3e3 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -75,7 +75,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: blocked - summary: signed canary .12 and updater isolation passed, but native Mail activation failed before reaching Dispatch; the Electron redirect preflight repair now passes local tests and independent review, while a fresh signed canary and the full installed-app acceptance story still block handoff readiness + summary: signed canary .13 passed updater isolation and a fresh-profile native Mail activation reached the canonical Dispatch credential ceremony; authenticated same-account continuity, restart, sign-out, account-switch, and isolation acceptance remain blocked on a safe Dispatch test identity verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -112,6 +112,12 @@ acceptance-state: - two fresh post-publication profiles proved the identity control renders but native activation does not reach Dispatch; both short production windows were rolled back in reverse order and Mail and Dispatch were restored unlocked to ce426fe - Electron 41.9 runtime probes proved session.fetch manual redirects throw Redirect was cancelled, followed fetches omit response.url, and net.request manual mode exposes the first redirect event - the replacement native redirect resolver passes 19 focused identity/navigation tests, 228 full Desktop tests, Desktop typecheck, formatting, and diff checks; independent review found no redirect-trust, request-lifecycle, cancellation, cookie-isolation, or logging defect + - exact-head required CI run 30555246811 and signed canary run 30555243503 passed on d19c92f1d1ebe3ab336506e9f27e6efa9125da25 + - downloaded canary 0.1.150-desktop-sso-canary.13 matched all four manifest SHA-256 values and passed strict deep codesign, stapler, Gatekeeper, bundle id, version, signer, and arm64 checks locally + - independent pre-publication QA on canary .13 passed a fresh-profile launch, disabled updater observation, real quit, process-absence check, and relaunch without touching the stable Desktop app + - the short locked production canary published immutable candidates 6a6a48cbd8ca3400088ba95f (Mail) and 6a6a48cbc9c76200085e212c (Dispatch); valid canonical routes returned the expected 302 chain and hostile redirect and forged callback requests returned 400 + - fresh-profile native acceptance on canary .13 passed Mail activation into the canonical Dispatch sign-in ceremony with the exact Mail app id and callback; the run stopped safely when Dispatch presented credential fields + - the canary window rolled back in reverse order to current-main 13f7e6bceceb52f04b69201f911729c174524041 using Mail deploy 6a6b46a26b16cb0008e0cf26 and Dispatch deploy 6a6b46a2ef65c600082759f5; both sites were unlocked, temporary identity routes returned baseline 401, Mail root retained normal redirect behavior, and canary and updater processes were absent implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -124,8 +130,6 @@ acceptance-state: - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption blockers: - - the redirect-preflight repair needs exact-head required CI, a newly signed and notarized canary, and another updater-isolation preflight before reopening the short canonical-origin window - - the installed canary must prove native Mail activation reaches Dispatch before authenticated acceptance continues - a safe authenticated Dispatch test identity is required to complete same-account Mail data, restart, sign-out, account-switch, isolation, and hostile-flow acceptance last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: @@ -139,6 +143,6 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-work-r14 +ledger-revision: desktop-sso-work-r15 status: active ``` From 652fc57b68408812af748302141df1f01d6ab95c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:33:32 -0400 Subject: [PATCH 16/31] fix(desktop): sign in through identity authority --- .agents/skills/authentication/SKILL.md | 5 + .../src/main/desktop-identity.spec.ts | 176 ++++++++++++++++++ .../desktop-app/src/main/desktop-identity.ts | 136 ++++++++++++++ packages/desktop-app/src/main/index.ts | 8 +- 4 files changed, 320 insertions(+), 5 deletions(-) diff --git a/.agents/skills/authentication/SKILL.md b/.agents/skills/authentication/SKILL.md index d48898aae2..15c147e6c5 100644 --- a/.agents/skills/authentication/SKILL.md +++ b/.agents/skills/authentication/SKILL.md @@ -176,6 +176,11 @@ Electron transfers only that target app's session cookie into its isolated JWT, or one app's session to another app, and never expose those values through renderer IPC. +The explicit Desktop account sign-in authenticates that dedicated partition +directly against canonical production Dispatch before app-by-app federation. +It must verify the resulting Dispatch session cookie rather than treating a +navigation or OAuth callback as proof of authentication. + Custom, user-added, and third-party apps are excluded. Preserve direct web sign-in and the per-app `AGENT_NATIVE_IDENTITY_HUB_URL` opt-in/canary behavior. The `desktop-sso.json` broker is only a loopback, non-production local diff --git a/packages/desktop-app/src/main/desktop-identity.spec.ts b/packages/desktop-app/src/main/desktop-identity.spec.ts index 7ebd3ffcea..b9682fd91b 100644 --- a/packages/desktop-app/src/main/desktop-identity.spec.ts +++ b/packages/desktop-app/src/main/desktop-identity.spec.ts @@ -5,6 +5,7 @@ import { DesktopIdentityBroker, desktopWorkspaceLogoutPath, isDesktopIdentityAuthorizeNavigation, + isDesktopIdentityAuthorityLanding, isDesktopIdentityCompletion, isDesktopIdentityConfiguredAppEligible, isDesktopSignInNavigation, @@ -182,6 +183,25 @@ describe("Desktop identity navigation boundaries", () => { ).toBe(false); }); + it("accepts an authority landing only on the exact authority origin", () => { + const authority = authorityFixture(); + expect( + isDesktopIdentityAuthorityLanding( + "https://dispatch.agent-native.com/", + authority, + ), + ).toBe(true); + expect( + isDesktopIdentityAuthorityLanding("https://evil.example/", authority), + ).toBe(false); + expect( + isDesktopIdentityAuthorityLanding( + "https://dispatch.agent-native.com/_agent-native/sign-in", + authority, + ), + ).toBe(false); + }); + it("recognizes workspace logout only on the canonical app origin", () => { expect( isDesktopWorkspaceLogoutRequest( @@ -223,6 +243,162 @@ describe("Desktop identity navigation boundaries", () => { }); describe("DesktopIdentityBroker", () => { + it("signs in to the authority directly and requires its session cookie", async () => { + const authority = authorityFixture(); + const identityCookies = cookieStore(); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents, + loadURL: vi.fn(async () => {}), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const handleOAuthNavigation = vi.fn(() => false); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === authority.id ? authority : null), + createWindow: () => identityWindow as never, + handleOAuthNavigation, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const first = broker.signInAuthority(authority.id); + expect(broker.signInAuthority(authority.id)).toBe(first); + await vi.waitFor(() => expect(identityWindow.loadURL).toHaveBeenCalled()); + expect(identityWindow.loadURL).toHaveBeenCalledWith( + "https://dispatch.agent-native.com/_agent-native/sign-in?return=%2F", + ); + + const didNavigate = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + didNavigate({}, authority.origin); + await Promise.resolve(); + expect(broker.getStatus()).toBe("signing-in"); + + await identityCookies.set({ + url: authority.origin, + name: "an_session", + value: "fake-session-value", + }); + didNavigate({}, `${authority.origin}/`); + + await expect(first).resolves.toBe(true); + expect(broker.getStatus()).toBe("signed-in"); + expect(identityWindow.close).toHaveBeenCalledOnce(); + expect(closedListener).toBeDefined(); + }); + + it("does not accept a hostile authority landing and still delegates OAuth", async () => { + const authority = authorityFixture(); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents, + loadURL: vi.fn(async () => {}), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const handleOAuthNavigation = vi.fn(() => true); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === authority.id ? authority : null), + createWindow: () => identityWindow as never, + handleOAuthNavigation, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.signInAuthority(authority.id); + await vi.waitFor(() => expect(identityWindow.loadURL).toHaveBeenCalled()); + const willNavigate = webContents.on.mock.calls.find( + ([event]) => event === "will-navigate", + )?.[1]; + const didNavigate = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + const preventDefault = vi.fn(); + willNavigate( + { preventDefault }, + "https://accounts.google.com/o/oauth2/auth?client_id=fake-client-id", + ); + didNavigate({}, "https://evil.example/"); + + expect(preventDefault).toHaveBeenCalledOnce(); + expect(handleOAuthNavigation).toHaveBeenCalledWith( + expect.stringContaining("accounts.google.com"), + webContents, + ); + expect(broker.getStatus()).toBe("signing-in"); + closedListener?.(); + await expect(ceremony).resolves.toBe(false); + expect(broker.getStatus()).toBe("sign-in-required"); + }); + + it("allows a fresh authority sign-in after sign-out cancels the active one", async () => { + const authority = authorityFixture(); + const windows: Array<{ close: () => void }> = []; + const createWindow = vi.fn(() => { + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents: { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }, + loadURL: vi.fn(async () => {}), + isDestroyed: vi.fn(() => false), + close: vi.fn(() => closedListener?.()), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + windows.push(identityWindow); + return identityWindow as never; + }); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } as unknown as Electron.Session, + resolveApp: (id) => (id === authority.id ? authority : null), + createWindow, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const first = broker.signInAuthority(authority.id); + await vi.waitFor(() => expect(createWindow).toHaveBeenCalledOnce()); + await broker.signOut([authority]); + await expect(first).resolves.toBe(false); + + const second = broker.signInAuthority(authority.id); + expect(second).not.toBe(first); + await vi.waitFor(() => expect(createWindow).toHaveBeenCalledTimes(2)); + windows[1]?.close(); + await expect(second).resolves.toBe(false); + }); + it("loads the validated identity authority redirect", async () => { const app = appFixture(); const authority = authorityFixture(); diff --git a/packages/desktop-app/src/main/desktop-identity.ts b/packages/desktop-app/src/main/desktop-identity.ts index ac1f759dd4..dea0a82635 100644 --- a/packages/desktop-app/src/main/desktop-identity.ts +++ b/packages/desktop-app/src/main/desktop-identity.ts @@ -180,8 +180,25 @@ export function isDesktopIdentityAuthorizeNavigation( } } +export function isDesktopIdentityAuthorityLanding( + navigationUrl: string, + authorityApp: Pick, +): boolean { + try { + const parsed = new URL(navigationUrl); + return ( + authorityApp.identityAuthority === true && + parsed.origin === authorityApp.origin && + parsed.pathname === "/" + ); + } catch { + return false; + } +} + export class DesktopIdentityBroker { private readonly pendingByApp = new Map>(); + private pendingAuthority: Promise | null = null; private readonly unsupportedAppIds = new Set(); private readonly activeSessionCopies = new Set>(); private queue: Promise = Promise.resolve(); @@ -274,6 +291,26 @@ export class DesktopIdentityBroker { return this.ensureAppSession(appId); } + signInAuthority(appId: string): Promise { + if (this.pendingAuthority) return this.pendingAuthority; + + this.automaticSignInSuppressed = false; + const generation = this.ceremonyGeneration; + const operation = this.queue.then(async () => { + await this.signOutOperation; + return this.runAuthorityCeremony(appId, generation); + }); + this.queue = operation.then( + () => undefined, + () => undefined, + ); + this.pendingAuthority = operation; + void operation.finally(() => { + if (this.pendingAuthority === operation) this.pendingAuthority = null; + }); + return operation; + } + async prepareExternalSignOut( apps: DesktopIdentityApp[], options: { @@ -319,6 +356,7 @@ export class DesktopIdentityBroker { this.automaticSignInSuppressed = true; this.ceremonyGeneration += 1; this.pendingByApp.clear(); + this.pendingAuthority = null; this.closeActiveWindow(); this.updateSignOutIntent(options); if (this.signOutOperation) return this.signOutOperation; @@ -675,6 +713,104 @@ export class DesktopIdentityBroker { }); } + private async runAuthorityCeremony( + appId: string, + generation: number, + ): Promise { + if (!this.isCeremonyCurrent(generation)) return false; + const authorityApp = this.options.resolveApp(appId); + if (!authorityApp?.identityAuthority) return false; + + this.setStatus("signing-in"); + const signInUrl = new URL(DESKTOP_SIGN_IN_PATH, authorityApp.origin); + signInUrl.searchParams.set("return", "/"); + + const identityWindow = this.options.createWindow({ + width: 520, + height: 720, + title: "Sign in to Agent Native", + backgroundColor: "#111111", + parent: this.options.parentWindow?.() ?? undefined, + webPreferences: { + nodeIntegration: false, + contextIsolation: true, + sandbox: true, + session: this.options.identitySession, + }, + }); + this.activeWindow = identityWindow; + + return new Promise((resolve) => { + let settled = false; + let timer: ReturnType | undefined; + const finish = (ok: boolean, status: DesktopIdentityStatus) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + if (this.activeWindow === identityWindow) this.activeWindow = null; + if (this.isCeremonyCurrent(generation)) this.setStatus(status); + if (!identityWindow.isDestroyed()) identityWindow.close(); + resolve(ok); + }; + + const inspectOAuthNavigation = (event: Electron.Event, url: string) => { + if ( + this.options.handleOAuthNavigation?.(url, identityWindow.webContents) + ) { + event.preventDefault(); + } + }; + const inspectCompletedNavigation = ( + _event: Electron.Event, + url: string, + ) => { + if (!isDesktopIdentityAuthorityLanding(url, authorityApp)) return; + void this.hasAuthoritySession(authorityApp).then( + (hasSession) => { + if (!this.isCeremonyCurrent(generation)) { + finish(false, "sign-in-required"); + return; + } + if (hasSession) finish(true, "signed-in"); + }, + () => finish(false, "failed"), + ); + }; + + identityWindow.webContents.on("will-navigate", inspectOAuthNavigation); + identityWindow.webContents.on("will-redirect", inspectOAuthNavigation); + identityWindow.webContents.on("did-navigate", inspectCompletedNavigation); + identityWindow.webContents.setWindowOpenHandler(({ url }) => + this.options.handleWindowOpen + ? this.options.handleWindowOpen(identityWindow.webContents, url) + : { action: "deny" }, + ); + identityWindow.webContents.on("render-process-gone", () => + finish(false, "failed"), + ); + identityWindow.on("closed", () => finish(false, "sign-in-required")); + + timer = setTimeout( + () => finish(false, "sign-in-required"), + this.options.timeoutMs ?? DEFAULT_CEREMONY_TIMEOUT_MS, + ); + + void identityWindow.loadURL(signInUrl.toString()).catch(() => { + finish(false, "failed"); + }); + }); + } + + private async hasAuthoritySession( + authorityApp: DesktopIdentityApp, + ): Promise { + const cookies = await this.options.identitySession.cookies.get({ + url: authorityApp.origin, + }); + const allowed = new Set(authorityApp.cookieNames); + return cookies.some((cookie) => allowed.has(cookie.name)); + } + private async copyTargetSession( app: DesktopIdentityApp, generation: number, diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index c6804cc369..27fb93d7cf 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -1099,11 +1099,9 @@ ipcMain.handle(IPC.IDENTITY_STATUS_GET, (event) => { ipcMain.handle(IPC.IDENTITY_SIGN_IN, async (event) => { if (!isShellIdentityIpc(event) || !desktopIdentityBroker) return false; - const targetApp = - resolveDesktopIdentityApp("mail") ?? - listDesktopIdentityApps().find((candidate) => !candidate.identityAuthority); - if (!targetApp) return false; - return desktopIdentityBroker.signIn(targetApp.id); + const authorityApp = resolveDesktopIdentityApp("dispatch"); + if (!authorityApp) return false; + return desktopIdentityBroker.signInAuthority(authorityApp.id); }); ipcMain.handle(IPC.IDENTITY_SIGN_OUT, async (event) => { From aeeb061edc4edc7d195545c944da070c6572aaf7 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:34:14 -0400 Subject: [PATCH 17/31] docs: record direct desktop authority sign-in --- plans/desktop-sso-intent/work-ledger.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 9ef1c6b3e3..2acb0e23db 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -74,8 +74,8 @@ delegation-ceiling: product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: - status: blocked - summary: signed canary .13 passed updater isolation and a fresh-profile native Mail activation reached the canonical Dispatch credential ceremony; authenticated same-account continuity, restart, sign-out, account-switch, and isolation acceptance remain blocked on a safe Dispatch test identity + status: active + summary: Desktop now has a credential-free path to authenticate its dedicated identity partition directly against canonical production Dispatch; signed-canary and authenticated end-to-end acceptance remain verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -118,6 +118,7 @@ acceptance-state: - the short locked production canary published immutable candidates 6a6a48cbd8ca3400088ba95f (Mail) and 6a6a48cbc9c76200085e212c (Dispatch); valid canonical routes returned the expected 302 chain and hostile redirect and forged callback requests returned 400 - fresh-profile native acceptance on canary .13 passed Mail activation into the canonical Dispatch sign-in ceremony with the exact Mail app id and callback; the run stopped safely when Dispatch presented credential fields - the canary window rolled back in reverse order to current-main 13f7e6bceceb52f04b69201f911729c174524041 using Mail deploy 6a6b46a26b16cb0008e0cf26 and Dispatch deploy 6a6b46a2ef65c600082759f5; both sites were unlocked, temporary identity routes returned baseline 401, Mail root retained normal redirect behavior, and canary and updater processes were absent + - direct Dispatch authority sign-in implementation 652fc57b68408812af748302141df1f01d6ab95c passes all 232 Desktop tests, Desktop typecheck, production build, formatting, diff checks, and a credential-material diff scan implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -129,8 +130,9 @@ acceptance-state: - operator docs, all localized counterparts, authentication skill, and Core changeset - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption + - explicit Desktop account sign-in authenticates the dedicated identity partition against canonical production Dispatch and requires an allowlisted Dispatch session cookie before reporting success blockers: - - a safe authenticated Dispatch test identity is required to complete same-account Mail data, restart, sign-out, account-switch, isolation, and hostile-flow acceptance + - the new exact-head signed canary must pass native Google sign-in before the short Mail and Dispatch candidate publication window can complete same-account continuity, restart, sign-out, account-switch, isolation, and hostile-flow acceptance last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: allowed: @@ -143,6 +145,6 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-work-r15 +ledger-revision: desktop-sso-work-r16 status: active ``` From 20f6a6e3a1e968af85188e2440176bad7b113928 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 30 Jul 2026 13:49:25 -0400 Subject: [PATCH 18/31] docs: sync workspace authentication skill --- .../workspace-core/.agents/skills/authentication/SKILL.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md b/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md index d48898aae2..15c147e6c5 100644 --- a/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md +++ b/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md @@ -176,6 +176,11 @@ Electron transfers only that target app's session cookie into its isolated JWT, or one app's session to another app, and never expose those values through renderer IPC. +The explicit Desktop account sign-in authenticates that dedicated partition +directly against canonical production Dispatch before app-by-app federation. +It must verify the resulting Dispatch session cookie rather than treating a +navigation or OAuth callback as proof of authentication. + Custom, user-added, and third-party apps are excluded. Preserve direct web sign-in and the per-app `AGENT_NATIVE_IDENTITY_HUB_URL` opt-in/canary behavior. The `desktop-sso.json` broker is only a loopback, non-production local From 216f4a0918fcbfbd32bf78c4b41c311da05bbdad Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:29:36 -0400 Subject: [PATCH 19/31] fix(desktop): use app sign-in as workspace front door --- .agents/skills/authentication/SKILL.md | 9 +- packages/desktop-app/shared/ipc-channels.ts | 1 - .../src/main/desktop-identity.spec.ts | 244 +++++++----------- .../desktop-app/src/main/desktop-identity.ts | 158 ++---------- packages/desktop-app/src/main/index.ts | 12 +- .../desktop-app/src/preload/index.spec.ts | 3 - packages/desktop-app/src/preload/index.ts | 1 - .../src/renderer/components/AppSettings.tsx | 28 +- packages/desktop-app/src/renderer/global.d.ts | 1 - plans/desktop-sso-intent/work-ledger.md | 145 +++++++++-- 10 files changed, 247 insertions(+), 355 deletions(-) diff --git a/.agents/skills/authentication/SKILL.md b/.agents/skills/authentication/SKILL.md index 15c147e6c5..f87bbd3774 100644 --- a/.agents/skills/authentication/SKILL.md +++ b/.agents/skills/authentication/SKILL.md @@ -176,10 +176,11 @@ Electron transfers only that target app's session cookie into its isolated JWT, or one app's session to another app, and never expose those values through renderer IPC. -The explicit Desktop account sign-in authenticates that dedicated partition -directly against canonical production Dispatch before app-by-app federation. -It must verify the resulting Dispatch session cookie rather than treating a -navigation or OAuth callback as proof of authentication. +The ordinary sign-in entry in any canonical first-party Desktop app is the +workspace sign-in front door. Desktop runs that app's existing federation +ceremony in the dedicated partition, then returns only the app-local session to +the initiating app. Desktop Settings reports workspace status and signs out; it +must not expose Dispatch home as a sign-in completion surface. Custom, user-added, and third-party apps are excluded. Preserve direct web sign-in and the per-app `AGENT_NATIVE_IDENTITY_HUB_URL` opt-in/canary behavior. diff --git a/packages/desktop-app/shared/ipc-channels.ts b/packages/desktop-app/shared/ipc-channels.ts index 0735a0720a..4b41467628 100644 --- a/packages/desktop-app/shared/ipc-channels.ts +++ b/packages/desktop-app/shared/ipc-channels.ts @@ -29,7 +29,6 @@ export const IPC = { /** Desktop workspace identity (renderer intent/status only; no secrets) */ IDENTITY_STATUS_GET: "identity:status:get", IDENTITY_STATUS_CHANGED: "identity:status:changed", - IDENTITY_SIGN_IN: "identity:sign-in", IDENTITY_SIGN_OUT: "identity:sign-out", /** App config management (renderer ↔ main) */ diff --git a/packages/desktop-app/src/main/desktop-identity.spec.ts b/packages/desktop-app/src/main/desktop-identity.spec.ts index b9682fd91b..7aa4a18b9d 100644 --- a/packages/desktop-app/src/main/desktop-identity.spec.ts +++ b/packages/desktop-app/src/main/desktop-identity.spec.ts @@ -5,7 +5,6 @@ import { DesktopIdentityBroker, desktopWorkspaceLogoutPath, isDesktopIdentityAuthorizeNavigation, - isDesktopIdentityAuthorityLanding, isDesktopIdentityCompletion, isDesktopIdentityConfiguredAppEligible, isDesktopSignInNavigation, @@ -63,6 +62,13 @@ function authorityFixture(): DesktopIdentityApp { ...appFixture(), id: "dispatch", origin: "https://dispatch.agent-native.com", + cookieNames: ["an_session_dispatch", "an_session"], + cookieNamesToClear: [ + "an_session_dispatch", + "an_session", + "an_dispatch.session_token", + "__Secure-an_dispatch.session_token", + ], identityAuthority: true, }; } @@ -183,25 +189,6 @@ describe("Desktop identity navigation boundaries", () => { ).toBe(false); }); - it("accepts an authority landing only on the exact authority origin", () => { - const authority = authorityFixture(); - expect( - isDesktopIdentityAuthorityLanding( - "https://dispatch.agent-native.com/", - authority, - ), - ).toBe(true); - expect( - isDesktopIdentityAuthorityLanding("https://evil.example/", authority), - ).toBe(false); - expect( - isDesktopIdentityAuthorityLanding( - "https://dispatch.agent-native.com/_agent-native/sign-in", - authority, - ), - ).toBe(false); - }); - it("recognizes workspace logout only on the canonical app origin", () => { expect( isDesktopWorkspaceLogoutRequest( @@ -243,167 +230,77 @@ describe("Desktop identity navigation boundaries", () => { }); describe("DesktopIdentityBroker", () => { - it("signs in to the authority directly and requires its session cookie", async () => { + it("does not replace an active app-led ceremony with a stale cookie status", async () => { + const app = appFixture(); const authority = authorityFixture(); - const identityCookies = cookieStore(); - const webContents = { - on: vi.fn(), - setWindowOpenHandler: vi.fn(), - }; - let closedListener: (() => void) | undefined; - const identityWindow = { - webContents, - loadURL: vi.fn(async () => {}), - isDestroyed: vi.fn(() => false), - close: vi.fn(), - on: vi.fn((event: string, listener: () => void) => { - if (event === "closed") closedListener = listener; - }), - }; - const handleOAuthNavigation = vi.fn(() => false); + const statusCookies = deferred(); + const preflight = deferred(); const broker = new DesktopIdentityBroker({ identitySession: { - cookies: identityCookies, + cookies: { + ...cookieStore(), + get: vi.fn(() => statusCookies.promise), + }, clearStorageData: vi.fn(async () => {}), } as unknown as Electron.Session, - resolveApp: (id) => (id === authority.id ? authority : null), - createWindow: () => identityWindow as never, - handleOAuthNavigation, + resolveLoginRedirect: vi.fn(() => preflight.promise), + resolveApp: (id) => + id === app.id ? app : id === authority.id ? authority : null, + createWindow: vi.fn() as never, reloadApp: vi.fn(), clearLocalBroker: vi.fn(), }); - const first = broker.signInAuthority(authority.id); - expect(broker.signInAuthority(authority.id)).toBe(first); - await vi.waitFor(() => expect(identityWindow.loadURL).toHaveBeenCalled()); - expect(identityWindow.loadURL).toHaveBeenCalledWith( - "https://dispatch.agent-native.com/_agent-native/sign-in?return=%2F", - ); - - const didNavigate = webContents.on.mock.calls.find( - ([event]) => event === "did-navigate", - )?.[1]; - didNavigate({}, authority.origin); - await Promise.resolve(); + const refresh = broker.refreshStatus(authority); + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(broker.getStatus()).toBe("signing-in")); + statusCookies.resolve([ + sessionCookie("an_session_dispatch", authority.origin), + ]); + await refresh; expect(broker.getStatus()).toBe("signing-in"); - await identityCookies.set({ - url: authority.origin, - name: "an_session", - value: "fake-session-value", - }); - didNavigate({}, `${authority.origin}/`); - - await expect(first).resolves.toBe(true); - expect(broker.getStatus()).toBe("signed-in"); - expect(identityWindow.close).toHaveBeenCalledOnce(); - expect(closedListener).toBeDefined(); - }); - - it("does not accept a hostile authority landing and still delegates OAuth", async () => { - const authority = authorityFixture(); - const webContents = { - on: vi.fn(), - setWindowOpenHandler: vi.fn(), - }; - let closedListener: (() => void) | undefined; - const identityWindow = { - webContents, - loadURL: vi.fn(async () => {}), - isDestroyed: vi.fn(() => false), - close: vi.fn(), - on: vi.fn((event: string, listener: () => void) => { - if (event === "closed") closedListener = listener; - }), - }; - const handleOAuthNavigation = vi.fn(() => true); - const broker = new DesktopIdentityBroker({ - identitySession: { - cookies: cookieStore(), - clearStorageData: vi.fn(async () => {}), - } as unknown as Electron.Session, - resolveApp: (id) => (id === authority.id ? authority : null), - createWindow: () => identityWindow as never, - handleOAuthNavigation, - reloadApp: vi.fn(), - clearLocalBroker: vi.fn(), - }); - - const ceremony = broker.signInAuthority(authority.id); - await vi.waitFor(() => expect(identityWindow.loadURL).toHaveBeenCalled()); - const willNavigate = webContents.on.mock.calls.find( - ([event]) => event === "will-navigate", - )?.[1]; - const didNavigate = webContents.on.mock.calls.find( - ([event]) => event === "did-navigate", - )?.[1]; - const preventDefault = vi.fn(); - willNavigate( - { preventDefault }, - "https://accounts.google.com/o/oauth2/auth?client_id=fake-client-id", - ); - didNavigate({}, "https://evil.example/"); - - expect(preventDefault).toHaveBeenCalledOnce(); - expect(handleOAuthNavigation).toHaveBeenCalledWith( - expect.stringContaining("accounts.google.com"), - webContents, - ); - expect(broker.getStatus()).toBe("signing-in"); - closedListener?.(); + preflight.resolve(null); await expect(ceremony).resolves.toBe(false); - expect(broker.getStatus()).toBe("sign-in-required"); }); - it("allows a fresh authority sign-in after sign-out cancels the active one", async () => { + it("does not inspect or replace status during workspace sign-out", async () => { const authority = authorityFixture(); - const windows: Array<{ close: () => void }> = []; - const createWindow = vi.fn(() => { - let closedListener: (() => void) | undefined; - const identityWindow = { - webContents: { - on: vi.fn(), - setWindowOpenHandler: vi.fn(), - }, - loadURL: vi.fn(async () => {}), - isDestroyed: vi.fn(() => false), - close: vi.fn(() => closedListener?.()), - on: vi.fn((event: string, listener: () => void) => { - if (event === "closed") closedListener = listener; - }), - }; - windows.push(identityWindow); - return identityWindow as never; - }); + const identityCookies = cookieStore([ + sessionCookie("an_session_dispatch", authority.origin), + ]); + const centralCleanup = deferred(); + const clearStorageData = vi.fn(() => centralCleanup.promise); const broker = new DesktopIdentityBroker({ identitySession: { - cookies: cookieStore(), - clearStorageData: vi.fn(async () => {}), + cookies: identityCookies, + clearStorageData, fetch: vi.fn(async () => new Response(null, { status: 200 })), } as unknown as Electron.Session, resolveApp: (id) => (id === authority.id ? authority : null), - createWindow, + createWindow: vi.fn() as never, reloadApp: vi.fn(), clearLocalBroker: vi.fn(), }); - const first = broker.signInAuthority(authority.id); - await vi.waitFor(() => expect(createWindow).toHaveBeenCalledOnce()); - await broker.signOut([authority]); - await expect(first).resolves.toBe(false); + const signOut = broker.signOut([authority]); + await vi.waitFor(() => expect(clearStorageData).toHaveBeenCalledOnce()); + const cookieReadsBeforeRefresh = identityCookies.get.mock.calls.length; + await broker.refreshStatus(authority); - const second = broker.signInAuthority(authority.id); - expect(second).not.toBe(first); - await vi.waitFor(() => expect(createWindow).toHaveBeenCalledTimes(2)); - windows[1]?.close(); - await expect(second).resolves.toBe(false); + expect(identityCookies.get).toHaveBeenCalledTimes(cookieReadsBeforeRefresh); + centralCleanup.resolve(); + await signOut; + expect(broker.getStatus()).toBe("sign-in-required"); }); it("loads the validated identity authority redirect", async () => { const app = appFixture(); const authority = authorityFixture(); const resolvedUrl = authorizeUrl(authority, app); - const resolveLoginRedirect = vi.fn(async () => resolvedUrl); + const resolveLoginRedirect = vi.fn( + async (_loginUrl: string) => resolvedUrl, + ); let closedListener: (() => void) | undefined; const identityWindow = { webContents: { @@ -583,6 +480,53 @@ describe("DesktopIdentityBroker", () => { expect(closedListener).toBeDefined(); }); + it("accepts Dispatch's nonce-bound front-door shortcut without consuming its authority session", async () => { + const dispatch = authorityFixture(); + const identityCookies = cookieStore([ + sessionCookie("an_session_dispatch", dispatch.origin, "dispatch-session"), + sessionCookie("unrelated_cookie", dispatch.origin, "do-not-copy"), + ]); + const resolveLoginRedirect = vi.fn(async (loginUrl: string) => { + const returnPath = new URL(loginUrl).searchParams.get("return")!; + return new URL(returnPath, dispatch.origin).toString(); + }); + const createWindow = vi.fn(); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect, + resolveApp: (id) => (id === dispatch.id ? dispatch : null), + createWindow, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + await expect(broker.ensureAppSession(dispatch.id)).resolves.toBe(true); + const loginUrl = String(resolveLoginRedirect.mock.calls[0]?.[0]); + const returnPath = new URL(loginUrl).searchParams.get("return")!; + const completion = new URL(returnPath, dispatch.origin).toString(); + + expect(new URL(loginUrl).pathname).toBe("/_agent-native/identity/login"); + expect(new URL(completion).pathname).toBe(DESKTOP_IDENTITY_COMPLETE_PATH); + expect(createWindow).not.toHaveBeenCalled(); + expect(dispatch.session.cookies.set).toHaveBeenCalledTimes(1); + expect(dispatch.session.cookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + name: "an_session_dispatch", + value: "dispatch-session", + }), + ); + expect(dispatch.session.cookies.set).not.toHaveBeenCalledWith( + expect.objectContaining({ name: "unrelated_cookie" }), + ); + expect(identityCookies.remove).not.toHaveBeenCalled(); + expect(reloadApp).toHaveBeenCalledWith(dispatch); + expect(broker.getStatus()).toBe("signed-in"); + }); + it("revokes and clears canonical app sessions plus the central identity session", async () => { const app = appFixture(); app.identityAuthority = true; diff --git a/packages/desktop-app/src/main/desktop-identity.ts b/packages/desktop-app/src/main/desktop-identity.ts index dea0a82635..fa81a8d4f3 100644 --- a/packages/desktop-app/src/main/desktop-identity.ts +++ b/packages/desktop-app/src/main/desktop-identity.ts @@ -180,25 +180,8 @@ export function isDesktopIdentityAuthorizeNavigation( } } -export function isDesktopIdentityAuthorityLanding( - navigationUrl: string, - authorityApp: Pick, -): boolean { - try { - const parsed = new URL(navigationUrl); - return ( - authorityApp.identityAuthority === true && - parsed.origin === authorityApp.origin && - parsed.pathname === "/" - ); - } catch { - return false; - } -} - export class DesktopIdentityBroker { private readonly pendingByApp = new Map>(); - private pendingAuthority: Promise | null = null; private readonly unsupportedAppIds = new Set(); private readonly activeSessionCopies = new Set>(); private queue: Promise = Promise.resolve(); @@ -234,6 +217,9 @@ export class DesktopIdentityBroker { } async refreshStatus(authorityApp: DesktopIdentityApp | null): Promise { + const observedStatus = this.status; + const observedGeneration = this.ceremonyGeneration; + if (observedStatus === "signing-in" || this.signOutOperation) return; if (!authorityApp) { this.setStatus("idle"); return; @@ -241,6 +227,13 @@ export class DesktopIdentityBroker { const cookies = await this.options.identitySession.cookies.get({ url: authorityApp.origin, }); + if ( + this.status !== observedStatus || + this.ceremonyGeneration !== observedGeneration || + this.signOutOperation + ) { + return; + } const allowed = new Set(authorityApp.cookieNames); this.setStatus( cookies.some((cookie) => allowed.has(cookie.name)) @@ -291,26 +284,6 @@ export class DesktopIdentityBroker { return this.ensureAppSession(appId); } - signInAuthority(appId: string): Promise { - if (this.pendingAuthority) return this.pendingAuthority; - - this.automaticSignInSuppressed = false; - const generation = this.ceremonyGeneration; - const operation = this.queue.then(async () => { - await this.signOutOperation; - return this.runAuthorityCeremony(appId, generation); - }); - this.queue = operation.then( - () => undefined, - () => undefined, - ); - this.pendingAuthority = operation; - void operation.finally(() => { - if (this.pendingAuthority === operation) this.pendingAuthority = null; - }); - return operation; - } - async prepareExternalSignOut( apps: DesktopIdentityApp[], options: { @@ -356,7 +329,6 @@ export class DesktopIdentityBroker { this.automaticSignInSuppressed = true; this.ceremonyGeneration += 1; this.pendingByApp.clear(); - this.pendingAuthority = null; this.closeActiveWindow(); this.updateSignOutIntent(options); if (this.signOutOperation) return this.signOutOperation; @@ -617,6 +589,18 @@ export class DesktopIdentityBroker { return false; } initialUrl = redirectUrl; + if (isDesktopIdentityCompletion(initialUrl, app, nonce)) { + try { + await this.trackSessionCopy(this.copyTargetSession(app, generation)); + if (!this.isCeremonyCurrent(generation)) return false; + this.options.reloadApp(app); + this.setStatus("signed-in"); + return true; + } catch { + if (this.isCeremonyCurrent(generation)) this.setStatus("failed"); + return false; + } + } const authorityApp = this.options.resolveApp("dispatch"); if ( !authorityApp || @@ -713,104 +697,6 @@ export class DesktopIdentityBroker { }); } - private async runAuthorityCeremony( - appId: string, - generation: number, - ): Promise { - if (!this.isCeremonyCurrent(generation)) return false; - const authorityApp = this.options.resolveApp(appId); - if (!authorityApp?.identityAuthority) return false; - - this.setStatus("signing-in"); - const signInUrl = new URL(DESKTOP_SIGN_IN_PATH, authorityApp.origin); - signInUrl.searchParams.set("return", "/"); - - const identityWindow = this.options.createWindow({ - width: 520, - height: 720, - title: "Sign in to Agent Native", - backgroundColor: "#111111", - parent: this.options.parentWindow?.() ?? undefined, - webPreferences: { - nodeIntegration: false, - contextIsolation: true, - sandbox: true, - session: this.options.identitySession, - }, - }); - this.activeWindow = identityWindow; - - return new Promise((resolve) => { - let settled = false; - let timer: ReturnType | undefined; - const finish = (ok: boolean, status: DesktopIdentityStatus) => { - if (settled) return; - settled = true; - if (timer) clearTimeout(timer); - if (this.activeWindow === identityWindow) this.activeWindow = null; - if (this.isCeremonyCurrent(generation)) this.setStatus(status); - if (!identityWindow.isDestroyed()) identityWindow.close(); - resolve(ok); - }; - - const inspectOAuthNavigation = (event: Electron.Event, url: string) => { - if ( - this.options.handleOAuthNavigation?.(url, identityWindow.webContents) - ) { - event.preventDefault(); - } - }; - const inspectCompletedNavigation = ( - _event: Electron.Event, - url: string, - ) => { - if (!isDesktopIdentityAuthorityLanding(url, authorityApp)) return; - void this.hasAuthoritySession(authorityApp).then( - (hasSession) => { - if (!this.isCeremonyCurrent(generation)) { - finish(false, "sign-in-required"); - return; - } - if (hasSession) finish(true, "signed-in"); - }, - () => finish(false, "failed"), - ); - }; - - identityWindow.webContents.on("will-navigate", inspectOAuthNavigation); - identityWindow.webContents.on("will-redirect", inspectOAuthNavigation); - identityWindow.webContents.on("did-navigate", inspectCompletedNavigation); - identityWindow.webContents.setWindowOpenHandler(({ url }) => - this.options.handleWindowOpen - ? this.options.handleWindowOpen(identityWindow.webContents, url) - : { action: "deny" }, - ); - identityWindow.webContents.on("render-process-gone", () => - finish(false, "failed"), - ); - identityWindow.on("closed", () => finish(false, "sign-in-required")); - - timer = setTimeout( - () => finish(false, "sign-in-required"), - this.options.timeoutMs ?? DEFAULT_CEREMONY_TIMEOUT_MS, - ); - - void identityWindow.loadURL(signInUrl.toString()).catch(() => { - finish(false, "failed"); - }); - }); - } - - private async hasAuthoritySession( - authorityApp: DesktopIdentityApp, - ): Promise { - const cookies = await this.options.identitySession.cookies.get({ - url: authorityApp.origin, - }); - const allowed = new Set(authorityApp.cookieNames); - return cookies.some((cookie) => allowed.has(cookie.name)); - } - private async copyTargetSession( app: DesktopIdentityApp, generation: number, diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 27fb93d7cf..a2f614d42d 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -1091,19 +1091,15 @@ function isShellIdentityIpc(event: IpcMainInvokeEvent): boolean { ); } -ipcMain.handle(IPC.IDENTITY_STATUS_GET, (event) => { +ipcMain.handle(IPC.IDENTITY_STATUS_GET, async (event) => { if (!isShellIdentityIpc(event)) return "failed" satisfies DesktopIdentityStatus; + await desktopIdentityBroker?.refreshStatus( + resolveDesktopIdentityApp("dispatch"), + ); return desktopIdentityBroker?.getStatus() ?? "idle"; }); -ipcMain.handle(IPC.IDENTITY_SIGN_IN, async (event) => { - if (!isShellIdentityIpc(event) || !desktopIdentityBroker) return false; - const authorityApp = resolveDesktopIdentityApp("dispatch"); - if (!authorityApp) return false; - return desktopIdentityBroker.signInAuthority(authorityApp.id); -}); - ipcMain.handle(IPC.IDENTITY_SIGN_OUT, async (event) => { if (!isShellIdentityIpc(event) || !desktopIdentityBroker) return false; await desktopIdentityBroker.signOut(listDesktopIdentityCleanupApps()); diff --git a/packages/desktop-app/src/preload/index.spec.ts b/packages/desktop-app/src/preload/index.spec.ts index a0568c6be8..bf4b6d626b 100644 --- a/packages/desktop-app/src/preload/index.spec.ts +++ b/packages/desktop-app/src/preload/index.spec.ts @@ -156,7 +156,6 @@ describe("workspace identity preload API", () => { electron.exposed as { identity: { getStatus(): Promise; - signIn(): Promise; signOut(): Promise; onStatusChange(callback: (status: string) => void): () => void; }; @@ -164,10 +163,8 @@ describe("workspace identity preload API", () => { ).identity; await api.getStatus(); - await api.signIn(); await api.signOut(); expect(electron.invoke).toHaveBeenCalledWith(IPC.IDENTITY_STATUS_GET); - expect(electron.invoke).toHaveBeenCalledWith(IPC.IDENTITY_SIGN_IN); expect(electron.invoke).toHaveBeenCalledWith(IPC.IDENTITY_SIGN_OUT); expect(api).not.toHaveProperty("cookie"); expect(api).not.toHaveProperty("token"); diff --git a/packages/desktop-app/src/preload/index.ts b/packages/desktop-app/src/preload/index.ts index 0d7f1b677a..1b313beb37 100644 --- a/packages/desktop-app/src/preload/index.ts +++ b/packages/desktop-app/src/preload/index.ts @@ -210,7 +210,6 @@ const electronAPI = { identity: { getStatus: (): Promise => ipcRenderer.invoke(IPC.IDENTITY_STATUS_GET), - signIn: (): Promise => ipcRenderer.invoke(IPC.IDENTITY_SIGN_IN), signOut: (): Promise => ipcRenderer.invoke(IPC.IDENTITY_SIGN_OUT), onStatusChange: ( cb: (status: DesktopIdentityStatus) => void, diff --git a/packages/desktop-app/src/renderer/components/AppSettings.tsx b/packages/desktop-app/src/renderer/components/AppSettings.tsx index c6bc9dd034..c77f4172ba 100644 --- a/packages/desktop-app/src/renderer/components/AppSettings.tsx +++ b/packages/desktop-app/src/renderer/components/AppSettings.tsx @@ -32,7 +32,6 @@ import { IconFolderPlus, IconAlertCircle, IconKeyboard, - IconLogin2, IconLogout2, IconUserCircle, } from "@tabler/icons-react"; @@ -571,13 +570,6 @@ export default function AppSettings({ return api.onStatusChange(setIdentityStatus); }, []); - const handleIdentitySignIn = useCallback(async () => { - const api = window.electronAPI?.identity; - if (!api) return; - await api.signIn(); - setIdentityStatus(await api.getStatus()); - }, []); - const handleIdentitySignOut = useCallback(async () => { const api = window.electronAPI?.identity; if (!api) return; @@ -913,13 +905,13 @@ export default function AppSettings({ : identityStatus === "signing-in" ? "Signing in to your workspace…" : identityStatus === "failed" - ? "Sign-in failed. Try again." - : "Sign in once, then open every first-party app"} + ? "Sign-in failed. Return to the app and try again." + : "Open any first-party app and sign in there once"}
- {identityStatus === "signed-in" ? ( + {identityStatus === "signed-in" && ( - ) : ( - )}
diff --git a/packages/desktop-app/src/renderer/global.d.ts b/packages/desktop-app/src/renderer/global.d.ts index a8cdbc67b3..e6f0c1ea97 100644 --- a/packages/desktop-app/src/renderer/global.d.ts +++ b/packages/desktop-app/src/renderer/global.d.ts @@ -683,7 +683,6 @@ interface ElectronAPI { identity: { getStatus(): Promise; - signIn(): Promise; signOut(): Promise; onStatusChange(cb: (status: DesktopIdentityStatus) => void): () => void; }; diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 2acb0e23db..2509916d39 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -2,7 +2,7 @@ ```yaml stage: work -authority-source: "Alice: $work after the desktop SSO Land no-go packet." +authority-source: "Alice: Yes, you can work it, approving desktop-first-party-sso-front-door-v2." authorized-scope: repositories: - BuilderIO/agent-native @@ -23,59 +23,101 @@ allowed-mutations: write-targets: artifacts: - packages/desktop-app - - packages/core - - docs - - .agents/skills/authentication - - .changeset - - .github/workflows/desktop-canary.yml + - .agents/skills/authentication/SKILL.md - plans/desktop-sso-intent/work-ledger.md - - /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md governing-artifact: - path: /Users/alicemoore/.codex/worktrees/52a5/agent-native/plans/desktop-sso-intent/implementation-brief.md - revision: desktop-sso-brief-r1 + path: plans/desktop-sso-intent/work-ledger.md + revision: desktop-sso-work-r18 architecture-fingerprint: - outcome: one Desktop workspace identity ceremony followed by silent app-local federation + outcome: one Agent Native workspace sign-in begun from the ordinary front door of any canonical first-party app, followed by silent app-local federation shipping-surfaces: - id: desktop-shell repository: BuilderIO/agent-native product-surface: signed packaged Agent Native Desktop app constituency: users of canonical first-party Agent Native apps - durable-destination: Agent Native Desktop release + durable-destination: PR #2290 exact head plus its signed branch-canary artifact; stable Desktop release remains outside this lane + integration-action: push - id: core-identity repository: BuilderIO/agent-native product-surface: public Core authentication and identity-federation runtime constituency: source-blind Agent Native developers and app users - durable-destination: published @agent-native/core package + durable-destination: PR #2290 exact head with its Core changeset; package publication remains outside this lane + integration-action: push - id: dispatch-authority repository: BuilderIO/agent-native product-surface: Dispatch identity authorize flow constituency: first-party workspace users and self-hosted workspace operators - durable-destination: Dispatch template and deployment + durable-destination: PR #2290 exact head; stable Dispatch integration remains outside this lane + integration-action: push - id: hosted-app-fleet repository: BuilderIO/agent-native - product-surface: canonical first-party hosted app deployments - constituency: Agent Native Desktop users - durable-destination: per-app production deployments and auth configuration - governing-architecture: Electron main owns one dedicated Dispatch identity partition and uses existing app-local federation; local development retains its loopback file broker; custom apps and Builder Connect remain outside the boundary + product-surface: short locked canonical-origin Mail and Dispatch canary deployments + constituency: supervised pre-merge canary testers + durable-destination: exact candidate and rollback receipts recorded in the ledger, with canonical production restored after the test + integration-action: deploy + governing-architecture: Electron main keeps one dedicated Dispatch identity partition, intercepts the ordinary sign-in entry of an eligible canonical app, and uses existing app-local federation; Settings is status, sign-out, and recovery rather than the primary identity front door; local development retains its loopback file broker; custom apps and Builder Connect remain outside the boundary acceptance-story: - id: desktop-first-party-sso-v1 - summary: one Desktop sign-in opens every canonical first-party app as the same verified account while preserving app-local sessions, databases, authorization, and data isolation + id: desktop-first-party-sso-front-door-v2 + summary: signing in normally from any eligible canonical first-party app establishes the shared Agent Native workspace identity, returns to that initiating app without exposing Dispatch home, and lets every later canonical app open as the same verified account while preserving app-local sessions, databases, authorization, and data isolation required-assertions: - signed packaged Desktop and real canary deployment pass before merge + - the ordinary sign-in entry in each enabled canonical first-party app, including Dispatch, can initiate the shared workspace ceremony and returns to the initiating app without using Dispatch home as a completion screen + - the Desktop Settings account card accurately reflects an existing authority session and never presents Dispatch home as successful sign-in; any signed-out action hands off to an app front door or is clearly labeled as recovery - every enabled canonical first-party app resolves the correct existing account and data without another credential ceremony - restart, workspace sign-out, account switch, standalone browser, custom app, and local-development behavior pass - hostile redirect, nonce, origin, cookie, concurrency, cancellation, and logging cases fail closed + - Agent Native identity sign-in remains separate from app-specific provider connection and consent - Builder internal and Builder credentials do not participate risk-strategy: kind: system-ready production-validation-after-merge: false delegation-ceiling: - read-only inventory and verification +architecture-grounding: + applicability: required + reason: authentication spans Desktop, Core, Dispatch, and every participating first-party deployment + status: grounded + demonstrated-callers: + - pre-fix Desktop Settings account Sign in invoked IPC.IDENTITY_SIGN_IN and the direct Dispatch authority ceremony + - a canonical app webview reaching its exact /_agent-native/sign-in invokes the existing app-targeted Desktop federation ceremony + existing-primitives: + - DesktopIdentityBroker.ensureAppSession uses the initiating app's existing identity/login route and copies only that app's allowlisted local session + - the pre-fix DesktopIdentityBroker.signInAuthority opened Dispatch's generic sign-in document in the dedicated identity partition + - Core's generic sign-in document redirects an already-authenticated session to its validated continuation + - Dispatch remains the existing identity authority and authorize endpoint + ownership-boundaries: + - Desktop main owns partitions, ceremony lifecycle, trusted app resolution, cookie transfer, and bounded status IPC + - Core owns the generic sign-in journey and trusted-app federation client/callback + - Dispatch owns human authentication, redirect validation, and identity assertion minting + - each target app owns its local user, session, authorization, provider grants, and data + legacy-contracts: + - preserve legacy ?return= consumption on /_agent-native/sign-in + - preserve standalone browser, direct app login, local loopback broker, custom-app exclusion, app-local databases, and per-provider consent + - preserve the dedicated identity partition and exact app/origin/callback/cookie trust boundaries + shared-vocabulary: + - workspace sign-in means Agent Native identity federation, not provider connection sharing + - app front door means the app's ordinary sign-in entry, not a hidden Dispatch account page + - Settings account card is a secondary status, sign-out, and recovery surface + smallest-compatible-delta: route the Settings signed-out action through an eligible initiating app's existing ensureAppSession ceremony, preflight and reflect an already-valid authority session before opening any window, and retain Dispatch's generic sign-in only as the identity-provider step inside that app-led ceremony + deferred-capabilities: + - custom and third-party app federation + - shared provider OAuth grants or automatic provider-scope expansion + - replacing Dispatch, Better Auth, or app-local sessions + reversibility: Desktop-only routing and status behavior can be reverted without schema, credential, provider, or deployment-data migration; hosted federation remains opt-in per app + direct-evidence: + - the Clip shows Settings Sign in visibly landing on Dispatch home instead of returning to an initiating app + - pre-fix source routed Settings directly to signInAuthority(dispatch) and used Dispatch root as its completion observation + - current source already intercepts exact sign-in navigation for eligible canonical apps and completes app-local federation in the dedicated identity partition + - Core documentation defines federation as happening at the app front door and preserves app-local sessions + inferences: + - the Clip's immediate root redirect is consistent with a pre-existing Dispatch session in the identity partition, but the Clip does not expose cookie or IPC status evidence + - Dispatch self-federation is source-supported but still requires current signed-runtime acceptance + unresolved-owner-questions: [] product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: - status: active - summary: Desktop now has a credential-free path to authenticate its dedicated identity partition directly against canonical production Dispatch; signed-canary and authenticated end-to-end acceptance remain + status: pending + summary: Alice approved the app-front-door replacement story; implementation and exact-head acceptance are active while production remains on normal unlocked main deployments verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -118,21 +160,26 @@ acceptance-state: - the short locked production canary published immutable candidates 6a6a48cbd8ca3400088ba95f (Mail) and 6a6a48cbc9c76200085e212c (Dispatch); valid canonical routes returned the expected 302 chain and hostile redirect and forged callback requests returned 400 - fresh-profile native acceptance on canary .13 passed Mail activation into the canonical Dispatch sign-in ceremony with the exact Mail app id and callback; the run stopped safely when Dispatch presented credential fields - the canary window rolled back in reverse order to current-main 13f7e6bceceb52f04b69201f911729c174524041 using Mail deploy 6a6b46a26b16cb0008e0cf26 and Dispatch deploy 6a6b46a2ef65c600082759f5; both sites were unlocked, temporary identity routes returned baseline 401, Mail root retained normal redirect behavior, and canary and updater processes were absent - - direct Dispatch authority sign-in implementation 652fc57b68408812af748302141df1f01d6ab95c passes all 232 Desktop tests, Desktop typecheck, production build, formatting, diff checks, and a credential-material diff scan + - superseded direct Dispatch authority sign-in implementation 652fc57b68408812af748302141df1f01d6ab95c passed all 232 Desktop tests, Desktop typecheck, production build, formatting, diff checks, and a credential-material diff scan before the front-door acceptance story invalidated it + - front-door replacement removes the Settings authority sign-in command end to end across renderer, preload, IPC, main, broker, tests, and authentication guidance while retaining status and workspace sign-out + - live status refresh now rejects stale results when a sign-in ceremony or workspace sign-out begins during cookie I/O and skips authority-cookie inspection throughout an active sign-out + - Dispatch as the initiating app now accepts only its exact same-origin nonce-bound desktop completion when Core detects the existing authority session, copies only the allowlisted Dispatch app session, retains the dedicated authority cookie, and never opens Dispatch home + - replacement front-door Desktop verification: all 231 tests across 24 files passed; Desktop TypeScript, production build, formatting, and git diff checks passed + - final independent read-only review found no remaining source-level defect or requested coverage gap in the status concurrency, direct-completion, cancellation, or cookie-isolation paths implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop - canonical-registry-only app session federation with target-cookie filtering - serialized and coalesced sign-in ceremonies with direct-login fallback - - renderer-safe status and sign-in/sign-out IPC without credential material + - renderer-safe status and sign-out IPC without credential material - workspace-wide Desktop sign-out preserves exact canonical POST logout and logout-all server semantics, retains request-start credentials only for the active cleanup operation, and reports partial failure truthfully - sign-out cleanup inventories every immutable canonical packaged production partition independently of sidebar enablement, Dev mode, or edited URLs while leaving localhost and custom origins untouched - operator docs, all localized counterparts, authentication skill, and Core changeset - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption - - explicit Desktop account sign-in authenticates the dedicated identity partition against canonical production Dispatch and requires an allowlisted Dispatch session cookie before reporting success + - the ordinary sign-in entry in a canonical first-party app starts the app-targeted workspace ceremony; Settings exposes status and workspace sign-out but no separate authority sign-in command blockers: - - the new exact-head signed canary must pass native Google sign-in before the short Mail and Dispatch candidate publication window can complete same-account continuity, restart, sign-out, account-switch, isolation, and hostile-flow acceptance + - after renewed Work authority, a new exact-head signed canary must pass native Google sign-in from an ordinary app front door before the short Mail and Dispatch candidate publication window can complete same-account continuity, restart, sign-out, account-switch, isolation, and hostile-flow acceptance last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: allowed: @@ -145,6 +192,52 @@ deployment-boundary: - merge or stable Desktop publication without a separate decision - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md -ledger-revision: desktop-sso-work-r16 +return-to-shape: + banner: resolved by Alice's explicit Work approval + invalidated-field: acceptance story + old-fingerprint: + outcome: one Desktop workspace identity ceremony followed by silent app-local federation + governing-architecture: dedicated Dispatch identity partition plus app-local federation, with Settings able to start a direct authority ceremony + acceptance-story: desktop-first-party-sso-v1 + risk-strategy: system-ready, production validation before merge + proposed-fingerprint: + outcome: one workspace sign-in begun at any canonical app's ordinary front door, followed by silent app-local federation + governing-architecture: dedicated Dispatch identity partition plus existing app-targeted federation; Settings is secondary status, sign-out, and recovery and never exposes Dispatch home as completion + acceptance-story: desktop-first-party-sso-front-door-v2 + risk-strategy: unchanged system-ready, production validation before merge + replacement-acceptance-story: desktop-first-party-sso-front-door-v2 approved +production-safety-preflight: + verified-at: 2026-07-30T15:18:53-04:00 + mail: normal main deploy 0f02c200b8a98914b0b5c3971b662eb7ebd8e5fa, unlocked, auto-publishing on, identity route baseline 401, root normal 302 + dispatch: newer legitimate normal main production deploy 6a6ba1de6ddf791274a85daa, unlocked, auto-publishing on, identity authorize baseline 401 + local: exact SSO Canary process was terminated after normal quit stalled; no Canary, ShipIt, or Squirrel process remains; stable Desktop untouched +ledger-revision: desktop-sso-work-r18 status: active ``` + +## Return-to-shape review: the sign-in front door + +### What changed + +The original product destination was “one human sign-in for every canonical first-party app,” but the current Settings implementation added a direct authority ceremony whose visible success path is Dispatch's ordinary home page. Alice's Clip and feedback establish a more specific successful-user story: the normal sign-in inside any individual Agent Native app should be the front door to the shared workspace identity. Dispatch may remain the identity authority underneath, but Dispatch home is not a user-facing completion screen. + +### Evidence-backed diagnosis + +- The pre-fix Settings button called `signInAuthority("dispatch")`, opened `/_agent-native/sign-in?return=/` in the dedicated identity partition, and treated Dispatch `/` plus an allowlisted cookie as completion. +- Core's generic sign-in document intentionally redirects an already-authenticated session to its validated return path. The Clip's immediate jump to `/` is therefore consistent with an existing authority session, although the recording alone cannot prove the cookie or final IPC status. +- The app-led path already exists: an eligible canonical app reaching its ordinary sign-in entry is intercepted, federates through Dispatch in the dedicated identity partition, mints a normal session in the initiating app, and copies only that app's allowlisted cookie into its own partition. +- Provider connection remains a separate concern. Signing into the Agent Native account once does not silently grant Gmail, Slack, Notion, or other provider scopes. + +### Options + +1. **Patch only the Dispatch-home flash.** Preflight the authority session before opening the Settings window. This is mechanically smallest, but it leaves Settings as a privileged direct sign-in path and does not fully answer the front-door feedback. +2. **Use the app front door everywhere (recommended).** Keep the dedicated identity partition and existing federation. Make ordinary canonical-app sign-in the primary entry; have any Settings signed-out action hand off to the current eligible app's same ceremony, with a preflight so an existing authority session completes silently. Settings remains useful for status, workspace sign-out, and recovery, but never lands on Dispatch home. +3. **Reuse or synchronize the visible Dispatch app partition.** This could make Dispatch itself the shared cookie jar, but it collapses or duplicates the existing authority/app partition boundary and adds account-switch and logout coupling without evidence that the larger change is needed. + +### Recommendation + +Approve option 2. It is the smallest delta that addresses both the concrete Clip defect and the product expectation. It keeps the existing identity authority, dedicated partition, per-app local sessions, and provider-consent boundaries. Implementation should delete or stop using the Settings-only direct-root completion path, route through the existing app-targeted ceremony, add an authority-session preflight, and test Dispatch as an initiating canonical app rather than assuming it works. + +### Replacement successful-user story + +A user opens any canonical first-party app in packaged Desktop and uses that app's normal sign-in. Agent Native performs one workspace identity ceremony, returns to the initiating app without exposing Dispatch home, and silently signs subsequent canonical apps into their correct existing local accounts. Settings accurately reports the shared account and supports workspace sign-out or recovery. Standalone browsers, custom apps, local development, Builder credentials, app-local authorization, and provider-specific connection consent remain unchanged. From c737f835ff00a39eea83061ebbb1a860202f75d6 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 30 Jul 2026 15:40:13 -0400 Subject: [PATCH 20/31] docs(auth): sync workspace authentication guidance --- .../.agents/skills/authentication/SKILL.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md b/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md index 15c147e6c5..f87bbd3774 100644 --- a/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md +++ b/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md @@ -176,10 +176,11 @@ Electron transfers only that target app's session cookie into its isolated JWT, or one app's session to another app, and never expose those values through renderer IPC. -The explicit Desktop account sign-in authenticates that dedicated partition -directly against canonical production Dispatch before app-by-app federation. -It must verify the resulting Dispatch session cookie rather than treating a -navigation or OAuth callback as proof of authentication. +The ordinary sign-in entry in any canonical first-party Desktop app is the +workspace sign-in front door. Desktop runs that app's existing federation +ceremony in the dedicated partition, then returns only the app-local session to +the initiating app. Desktop Settings reports workspace status and signs out; it +must not expose Dispatch home as a sign-in completion surface. Custom, user-added, and third-party apps are excluded. Preserve direct web sign-in and the per-app `AGENT_NATIVE_IDENTITY_HUB_URL` opt-in/canary behavior. From 302e992748ac16095915246460e1cf5419d5b014 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:15:55 -0400 Subject: [PATCH 21/31] docs(auth): refresh desktop SSO work evidence --- plans/desktop-sso-intent/work-ledger.md | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 2509916d39..79fd3835d0 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -27,7 +27,7 @@ write-targets: - plans/desktop-sso-intent/work-ledger.md governing-artifact: path: plans/desktop-sso-intent/work-ledger.md - revision: desktop-sso-work-r18 + revision: desktop-sso-work-r19 architecture-fingerprint: outcome: one Agent Native workspace sign-in begun from the ordinary front door of any canonical first-party app, followed by silent app-local federation shipping-surfaces: @@ -98,7 +98,7 @@ architecture-grounding: - workspace sign-in means Agent Native identity federation, not provider connection sharing - app front door means the app's ordinary sign-in entry, not a hidden Dispatch account page - Settings account card is a secondary status, sign-out, and recovery surface - smallest-compatible-delta: route the Settings signed-out action through an eligible initiating app's existing ensureAppSession ceremony, preflight and reflect an already-valid authority session before opening any window, and retain Dispatch's generic sign-in only as the identity-provider step inside that app-led ceremony + smallest-compatible-delta: remove the Settings-only authority sign-in command, make an eligible canonical app's ordinary sign-in entry the sole workspace front door, reflect authority status and workspace sign-out in Settings, and retain Dispatch's generic sign-in only as the identity-provider step inside the app-led ceremony deferred-capabilities: - custom and third-party app federation - shared provider OAuth grants or automatic provider-scope expansion @@ -117,7 +117,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: pending - summary: Alice approved the app-front-door replacement story; implementation and exact-head acceptance are active while production remains on normal unlocked main deployments + summary: Alice approved the app-front-door replacement story; current main is integrated and exact-head CI, signed-canary, and native acceptance must be regenerated while production remains on normal unlocked main deployments verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -166,6 +166,10 @@ acceptance-state: - Dispatch as the initiating app now accepts only its exact same-origin nonce-bound desktop completion when Core detects the existing authority session, copies only the allowlisted Dispatch app session, retains the dedicated authority cookie, and never opens Dispatch home - replacement front-door Desktop verification: all 231 tests across 24 files passed; Desktop TypeScript, production build, formatting, and git diff checks passed - final independent read-only review found no remaining source-level defect or requested coverage gap in the status concurrency, direct-completion, cancellation, or cookie-isolation paths + - current origin/main 3e89c5e5aea6752beea0849fbd62c5a1d58b986d was integrated without textual conflicts in merge commit 6e202d737ad3f84ba3801a38b596b118d1fa69a2; all four acceptance-relevant overlaps retain both the SSO behavior and current-main additions + - post-integration focused verification on 6e202d737: Desktop identity and updater 24 tests passed; Core identity 27; Dispatch primary auth 1; Dispatch template identity 24 + - post-integration full relevant verification on 6e202d737: Desktop 231 tests, Dispatch package 336, Dispatch template 41, Desktop/Core/Dispatch/Dispatch-template typechecks, Core and Dispatch builds, Desktop production compile, formatting hygiene, and git diff checks passed + - the broader Core package sweep passed 10080 tests and failed 228 tests because Node 24 exposed no browser localStorage in the Mac test runtime; every failing test source is unchanged from current main, so this is recorded as baseline harness evidence rather than SSO acceptance implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -179,7 +183,8 @@ acceptance-state: - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption - the ordinary sign-in entry in a canonical first-party app starts the app-targeted workspace ceremony; Settings exposes status and workspace sign-out but no separate authority sign-in command blockers: - - after renewed Work authority, a new exact-head signed canary must pass native Google sign-in from an ordinary app front door before the short Mail and Dispatch candidate publication window can complete same-account continuity, restart, sign-out, account-switch, isolation, and hostile-flow acceptance + - the integrated branch must be pushed and pass exact-head CI plus a new signed canary before native Google sign-in from an ordinary app front door can begin + - the short Mail and Dispatch candidate publication window must then complete same-account continuity, restart, sign-out, account-switch, isolation, provider-separation, and hostile-flow acceptance before Work can be complete last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: allowed: @@ -207,11 +212,11 @@ return-to-shape: risk-strategy: unchanged system-ready, production validation before merge replacement-acceptance-story: desktop-first-party-sso-front-door-v2 approved production-safety-preflight: - verified-at: 2026-07-30T15:18:53-04:00 - mail: normal main deploy 0f02c200b8a98914b0b5c3971b662eb7ebd8e5fa, unlocked, auto-publishing on, identity route baseline 401, root normal 302 - dispatch: newer legitimate normal main production deploy 6a6ba1de6ddf791274a85daa, unlocked, auto-publishing on, identity authorize baseline 401 - local: exact SSO Canary process was terminated after normal quit stalled; no Canary, ShipIt, or Squirrel process remains; stable Desktop untouched -ledger-revision: desktop-sso-work-r18 + verified-at: 2026-08-01T09:05:00-04:00 + mail: normal unlocked auto-publishing main deploy 6a6ddd1e966031000859bd7e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity route baseline 401, root normal 302 + dispatch: normal unlocked auto-publishing main deploy 6a6ddd1e8eef7d00084f719e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity authorize baseline 401 + local: no SSO Canary, ShipIt, or Squirrel process remains; stable Desktop untouched +ledger-revision: desktop-sso-work-r19 status: active ``` From 7535935d40475d57d715f323129a577d9b301838 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 1 Aug 2026 09:43:58 -0400 Subject: [PATCH 22/31] fix(desktop): isolate SSO canary profile --- .../src/main/desktop-startup.spec.ts | 93 +++++++++++++++++++ .../desktop-app/src/main/desktop-startup.ts | 54 +++++++++++ packages/desktop-app/src/main/index.ts | 28 +++--- .../src/main/ipc/update-policy.spec.ts | 24 ++++- .../desktop-app/src/main/ipc/update-policy.ts | 15 ++- plans/desktop-sso-intent/work-ledger.md | 12 ++- 6 files changed, 205 insertions(+), 21 deletions(-) create mode 100644 packages/desktop-app/src/main/desktop-startup.spec.ts create mode 100644 packages/desktop-app/src/main/desktop-startup.ts diff --git a/packages/desktop-app/src/main/desktop-startup.spec.ts b/packages/desktop-app/src/main/desktop-startup.spec.ts new file mode 100644 index 0000000000..f7ee99cfd1 --- /dev/null +++ b/packages/desktop-app/src/main/desktop-startup.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; + +import { initializeDesktopStartup } from "./desktop-startup.js"; + +function createDependencies( + overrides: Partial[0]> = {}, +) { + const events: string[] = []; + return { + events, + dependencies: { + isPackaged: true, + version: "0.1.150-desktop-sso-canary.20", + appDataPath: "/application-support", + createDirectory: vi.fn(() => events.push("create-directory")), + setUserDataPath: vi.fn(() => events.push("set-user-data")), + initializeSentry: vi.fn(() => events.push("sentry")), + initializeLogger: vi.fn(() => events.push("logger")), + logError: vi.fn(), + logWarning: vi.fn(), + ...overrides, + }, + }; +} + +describe("initializeDesktopStartup", () => { + it("isolates a packaged canary before initializing profile consumers", () => { + const { dependencies, events } = createDependencies(); + + initializeDesktopStartup(dependencies); + + expect(dependencies.createDirectory).toHaveBeenCalledWith( + "/application-support/Agent Native SSO Canary", + ); + expect(dependencies.setUserDataPath).toHaveBeenCalledWith( + "/application-support/Agent Native SSO Canary", + ); + expect(events).toEqual([ + "create-directory", + "set-user-data", + "sentry", + "logger", + ]); + }); + + it("aborts packaged canary startup when profile isolation fails", () => { + const failure = new Error("read-only volume"); + const { dependencies } = createDependencies({ + createDirectory: vi.fn(() => { + throw failure; + }), + }); + + expect(() => initializeDesktopStartup(dependencies)).toThrow(failure); + expect(dependencies.logError).toHaveBeenCalledWith( + "[main] failed to isolate packaged userData directory:", + failure, + ); + expect(dependencies.setUserDataPath).not.toHaveBeenCalled(); + expect(dependencies.initializeSentry).not.toHaveBeenCalled(); + expect(dependencies.initializeLogger).not.toHaveBeenCalled(); + }); + + it("preserves stable Desktop's existing profile", () => { + const { dependencies, events } = createDependencies({ + version: "0.1.150", + }); + + initializeDesktopStartup(dependencies); + + expect(dependencies.createDirectory).not.toHaveBeenCalled(); + expect(dependencies.setUserDataPath).not.toHaveBeenCalled(); + expect(events).toEqual(["sentry", "logger"]); + }); + + it("keeps development startup recoverable when profile isolation fails", () => { + const failure = new Error("read-only volume"); + const { dependencies, events } = createDependencies({ + isPackaged: false, + createDirectory: vi.fn(() => { + throw failure; + }), + }); + + initializeDesktopStartup(dependencies); + + expect(dependencies.logWarning).toHaveBeenCalledWith( + "[main] failed to isolate userData directory:", + failure, + ); + expect(events).toEqual(["sentry", "logger"]); + }); +}); diff --git a/packages/desktop-app/src/main/desktop-startup.ts b/packages/desktop-app/src/main/desktop-startup.ts new file mode 100644 index 0000000000..e86bb0310f --- /dev/null +++ b/packages/desktop-app/src/main/desktop-startup.ts @@ -0,0 +1,54 @@ +import path from "node:path"; + +import { resolveDesktopUserDataDirectoryName } from "./ipc/update-policy.js"; + +export interface DesktopStartupDependencies { + isPackaged: boolean; + version: string; + appDataPath: string; + createDirectory: (directoryPath: string) => void; + setUserDataPath: (directoryPath: string) => void; + initializeSentry: () => void; + initializeLogger: () => void; + logError: (message: string, error: unknown) => void; + logWarning: (message: string, error: unknown) => void; +} + +export function initializeDesktopStartup({ + isPackaged, + version, + appDataPath, + createDirectory, + setUserDataPath, + initializeSentry, + initializeLogger, + logError, + logWarning, +}: DesktopStartupDependencies): void { + const isolatedUserDataDirectoryName = resolveDesktopUserDataDirectoryName( + isPackaged, + version, + ); + if (isolatedUserDataDirectoryName) { + const isolatedUserDataPath = path.join( + appDataPath, + isolatedUserDataDirectoryName, + ); + try { + createDirectory(isolatedUserDataPath); + setUserDataPath(isolatedUserDataPath); + } catch (error) { + if (isPackaged) { + logError( + "[main] failed to isolate packaged userData directory:", + error, + ); + throw error; + } + logWarning("[main] failed to isolate userData directory:", error); + } + } + + initializeSentry(); + initializeLogger(); +} diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index 3d11e51e53..1d93e89879 100644 --- a/packages/desktop-app/src/main/index.ts +++ b/packages/desktop-app/src/main/index.ts @@ -184,6 +184,7 @@ import { revealLogFolder, getLogFilePath, } from "./desktop-logger"; +import { initializeDesktopStartup } from "./desktop-startup.js"; import { registerAppsIpc } from "./ipc/apps"; import { registerCodeAgentsIpc } from "./ipc/code-agents"; import { registerContentFilesIpc } from "./ipc/content-files"; @@ -209,8 +210,18 @@ import { } from "./sentry"; import { installWebviewNavigationListeners } from "./webview-navigation"; -initializeDesktopSentry(); -initializeDesktopLogger(); +initializeDesktopStartup({ + isPackaged: app.isPackaged, + version: app.getVersion(), + appDataPath: app.getPath("appData"), + createDirectory: (directoryPath) => + fs.mkdirSync(directoryPath, { recursive: true }), + setUserDataPath: (directoryPath) => app.setPath("userData", directoryPath), + initializeSentry: initializeDesktopSentry, + initializeLogger: initializeDesktopLogger, + logError: console.error, + logWarning: console.warn, +}); const DESKTOP_CODE_AGENT_PERSISTENCE_LOCK = { lockWaitMs: 50, @@ -241,19 +252,6 @@ process.on("uncaughtException", (err: NodeJS.ErrnoException) => { const IS_DEV = !app.isPackaged; -if (IS_DEV) { - // Keep local electron-vite runs out of the packaged app's Chromium profile. - // Sharing the same userData directory lets dev and prod processes fight over - // persisted webview storage (notably IndexedDB LevelDB LOCK files). - const devUserDataPath = path.join(app.getPath("appData"), "Agent Native Dev"); - try { - fs.mkdirSync(devUserDataPath, { recursive: true }); - app.setPath("userData", devUserDataPath); - } catch (err) { - console.warn("[main] failed to isolate dev userData directory:", err); - } -} - // ---------- User-Agent marker ---------- // Tag every request from this Electron app so the server can distinguish // Agent Native desktop from other Electron-based webviews (Builder.io's diff --git a/packages/desktop-app/src/main/ipc/update-policy.spec.ts b/packages/desktop-app/src/main/ipc/update-policy.spec.ts index 1c833f115c..31ee02abc5 100644 --- a/packages/desktop-app/src/main/ipc/update-policy.spec.ts +++ b/packages/desktop-app/src/main/ipc/update-policy.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vitest"; -import { resolveDesktopUpdateSupport } from "./update-policy.js"; +import { + resolveDesktopUpdateSupport, + resolveDesktopUserDataDirectoryName, +} from "./update-policy.js"; describe("resolveDesktopUpdateSupport", () => { it("disables updates in development", () => { @@ -33,4 +36,23 @@ describe("resolveDesktopUpdateSupport", () => { supported: true, }); }); + + it("isolates development and Desktop SSO canary profiles from stable Desktop", () => { + expect(resolveDesktopUserDataDirectoryName(false, "0.1.150")).toBe( + "Agent Native Dev", + ); + expect( + resolveDesktopUserDataDirectoryName( + true, + "0.1.150-desktop-sso-canary.19", + ), + ).toBe("Agent Native SSO Canary"); + expect(resolveDesktopUserDataDirectoryName(true, "0.1.150")).toBeNull(); + expect( + resolveDesktopUserDataDirectoryName( + true, + "0.1.150-desktop-sso-canary.not-a-run", + ), + ).toBeNull(); + }); }); diff --git a/packages/desktop-app/src/main/ipc/update-policy.ts b/packages/desktop-app/src/main/ipc/update-policy.ts index 2b77965ad7..724e0db055 100644 --- a/packages/desktop-app/src/main/ipc/update-policy.ts +++ b/packages/desktop-app/src/main/ipc/update-policy.ts @@ -1,5 +1,18 @@ const DESKTOP_SSO_CANARY_VERSION = /-desktop-sso-canary\.\d+$/; +export function isDesktopSsoCanaryVersion(version: string): boolean { + return DESKTOP_SSO_CANARY_VERSION.test(version); +} + +export function resolveDesktopUserDataDirectoryName( + isPackaged: boolean, + version: string, +): string | null { + if (!isPackaged) return "Agent Native Dev"; + if (isDesktopSsoCanaryVersion(version)) return "Agent Native SSO Canary"; + return null; +} + export type DesktopUpdateSupport = | { supported: true } | { supported: false; reason: string }; @@ -15,7 +28,7 @@ export function resolveDesktopUpdateSupport( }; } - if (DESKTOP_SSO_CANARY_VERSION.test(version)) { + if (isDesktopSsoCanaryVersion(version)) { return { supported: false, reason: "Auto-update is disabled for this Desktop SSO canary build", diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 79fd3835d0..45d6c777ce 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -27,7 +27,7 @@ write-targets: - plans/desktop-sso-intent/work-ledger.md governing-artifact: path: plans/desktop-sso-intent/work-ledger.md - revision: desktop-sso-work-r19 + revision: desktop-sso-work-r20 architecture-fingerprint: outcome: one Agent Native workspace sign-in begun from the ordinary front door of any canonical first-party app, followed by silent app-local federation shipping-surfaces: @@ -117,7 +117,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: pending - summary: Alice approved the app-front-door replacement story; current main is integrated and exact-head CI, signed-canary, and native acceptance must be regenerated while production remains on normal unlocked main deployments + summary: Alice approved the app-front-door replacement story; current main is integrated, the branch canary now has a fail-closed profile distinct from stable Desktop, and exact-head CI, signed-canary, and native acceptance must be regenerated while production remains on normal unlocked main deployments verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -170,6 +170,10 @@ acceptance-state: - post-integration focused verification on 6e202d737: Desktop identity and updater 24 tests passed; Core identity 27; Dispatch primary auth 1; Dispatch template identity 24 - post-integration full relevant verification on 6e202d737: Desktop 231 tests, Dispatch package 336, Dispatch template 41, Desktop/Core/Dispatch/Dispatch-template typechecks, Core and Dispatch builds, Desktop production compile, formatting hygiene, and git diff checks passed - the broader Core package sweep passed 10080 tests and failed 228 tests because Node 24 exposed no browser localStorage in the Mac test runtime; every failing test source is unchanged from current main, so this is recorded as baseline harness evidence rather than SSO acceptance + - local artifact inspection found that packaged Desktop SSO canaries still inherited stable Desktop's Electron userData path even though development was isolated; canary .19 was therefore not installed + - the packaged canary version family now selects Agent Native SSO Canary as its userData directory before Sentry or logger initialization and aborts launch if that isolated profile cannot be established, while stable Desktop retains its existing profile + - canary-profile fix verification: all 236 Desktop tests passed; Desktop TypeScript, production build, formatting, and git diff checks passed + - the startup regression suite proves profile creation and selection precede Sentry and logger initialization, packaged isolation failure aborts before either consumer starts, stable Desktop leaves its profile unchanged, and development retains its recoverable fallback implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -183,7 +187,7 @@ acceptance-state: - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption - the ordinary sign-in entry in a canonical first-party app starts the app-targeted workspace ceremony; Settings exposes status and workspace sign-out but no separate authority sign-in command blockers: - - the integrated branch must be pushed and pass exact-head CI plus a new signed canary before native Google sign-in from an ordinary app front door can begin + - the canary-profile fix must be independently reviewed, pushed, and pass exact-head CI plus a newly signed canary before native Google sign-in from an ordinary app front door can begin - the short Mail and Dispatch candidate publication window must then complete same-account continuity, restart, sign-out, account-switch, isolation, provider-separation, and hostile-flow acceptance before Work can be complete last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: @@ -216,7 +220,7 @@ production-safety-preflight: mail: normal unlocked auto-publishing main deploy 6a6ddd1e966031000859bd7e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity route baseline 401, root normal 302 dispatch: normal unlocked auto-publishing main deploy 6a6ddd1e8eef7d00084f719e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity authorize baseline 401 local: no SSO Canary, ShipIt, or Squirrel process remains; stable Desktop untouched -ledger-revision: desktop-sso-work-r19 +ledger-revision: desktop-sso-work-r20 status: active ``` From fdc6da02756f148aeff957b1b3358515cd0d6145 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:05:49 -0400 Subject: [PATCH 23/31] fix(desktop): wait for federated session cookie --- .../src/main/desktop-identity.spec.ts | 209 ++++++++++++++++++ .../desktop-app/src/main/desktop-identity.ts | 189 +++++++++++++--- plans/desktop-sso-intent/work-ledger.md | 16 +- 3 files changed, 375 insertions(+), 39 deletions(-) diff --git a/packages/desktop-app/src/main/desktop-identity.spec.ts b/packages/desktop-app/src/main/desktop-identity.spec.ts index 7aa4a18b9d..3057f21f56 100644 --- a/packages/desktop-app/src/main/desktop-identity.spec.ts +++ b/packages/desktop-app/src/main/desktop-identity.spec.ts @@ -420,6 +420,7 @@ describe("DesktopIdentityBroker", () => { sameSite: "lax", }, ]); + identityCookies.get.mockImplementationOnce(async () => []); const webContents = { on: vi.fn(), setWindowOpenHandler: vi.fn(), @@ -464,6 +465,7 @@ describe("DesktopIdentityBroker", () => { navigationHandler({ preventDefault }, completion); await expect(first).resolves.toBe(true); + expect(identityCookies.get).toHaveBeenCalledTimes(2); expect(preventDefault).toHaveBeenCalled(); expect(app.session.cookies.set).toHaveBeenCalledTimes(1); expect(app.session.cookies.set).toHaveBeenCalledWith( @@ -480,6 +482,213 @@ describe("DesktopIdentityBroker", () => { expect(closedListener).toBeDefined(); }); + it("recovers from a missing target cookie without logging session details", async () => { + const app = appFixture(); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const reloadApp = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + sessionCookieWaitMs: 50, + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "will-navigate", + )?.[1]; + navigationHandler({ preventDefault: vi.fn() }, completion); + + await expect(ceremony).resolves.toBe(false); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("failed"); + expect(warn).toHaveBeenCalledWith( + "[desktop-identity] target session transfer failed", + { appId: "mail" }, + ); + expect(JSON.stringify(warn.mock.calls)).not.toContain("session-value"); + warn.mockRestore(); + }); + + it("does not copy a cookie that appears after the identity window closes", async () => { + const app = appFixture(); + const identityCookies = cookieStore(); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + sessionCookieWaitMs: 500, + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "will-navigate", + )?.[1]; + navigationHandler({ preventDefault: vi.fn() }, completion); + await vi.waitFor(() => expect(identityCookies.get).toHaveBeenCalled()); + + closedListener?.(); + await identityCookies.set({ + url: app.origin, + name: "an_session_mail", + value: "late-session", + path: "/", + secure: true, + httpOnly: true, + sameSite: "lax", + }); + + await expect(ceremony).resolves.toBe(false); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(app.session.cookies.set).not.toHaveBeenCalled(); + expect(reloadApp).not.toHaveBeenCalled(); + expect(broker.getStatus()).toBe("sign-in-required"); + }); + + it("does not copy a cookie after the identity ceremony times out", async () => { + const app = appFixture(); + const identityCookies = cookieStore(); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + timeoutMs: 50, + sessionCookieWaitMs: 500, + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "will-navigate", + )?.[1]; + navigationHandler({ preventDefault: vi.fn() }, completion); + + await expect(ceremony).resolves.toBe(false); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(app.session.cookies.set).not.toHaveBeenCalled(); + expect(reloadApp).not.toHaveBeenCalled(); + expect(broker.getStatus()).toBe("sign-in-required"); + }); + + it("bounds a cookie-store read that never resolves", async () => { + const app = appFixture(); + const identityCookies = cookieStore(); + identityCookies.get.mockImplementation( + () => new Promise(() => {}), + ); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const reloadApp = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + sessionCookieWaitMs: 25, + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "will-navigate", + )?.[1]; + navigationHandler({ preventDefault: vi.fn() }, completion); + + await expect(ceremony).resolves.toBe(false); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("failed"); + warn.mockRestore(); + }); + it("accepts Dispatch's nonce-bound front-door shortcut without consuming its authority session", async () => { const dispatch = authorityFixture(); const identityCookies = cookieStore([ diff --git a/packages/desktop-app/src/main/desktop-identity.ts b/packages/desktop-app/src/main/desktop-identity.ts index fa81a8d4f3..3b5ac3ba06 100644 --- a/packages/desktop-app/src/main/desktop-identity.ts +++ b/packages/desktop-app/src/main/desktop-identity.ts @@ -19,6 +19,8 @@ const DESKTOP_IDENTITY_CALLBACK_PATH = "/_agent-native/identity/callback"; const DESKTOP_LOGOUT_PATH = "/_agent-native/auth/logout"; const DESKTOP_LOGOUT_ALL_PATH = "/_agent-native/auth/logout-all"; const DEFAULT_CEREMONY_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_SESSION_COOKIE_WAIT_MS = 2_000; +const SESSION_COOKIE_POLL_INTERVAL_MS = 25; export type DesktopWorkspaceLogoutPath = | typeof DESKTOP_LOGOUT_PATH @@ -107,6 +109,7 @@ export interface DesktopIdentityBrokerOptions { clearLocalBroker: () => Promise | void; onStatus?: (status: DesktopIdentityStatus) => void; timeoutMs?: number; + sessionCookieWaitMs?: number; } interface DesktopSignOutIntent { @@ -597,6 +600,7 @@ export class DesktopIdentityBroker { this.setStatus("signed-in"); return true; } catch { + this.recoverFromSessionCopyFailure(app, generation); if (this.isCeremonyCurrent(generation)) this.setStatus("failed"); return false; } @@ -631,11 +635,13 @@ export class DesktopIdentityBroker { this.activeWindow = identityWindow; return new Promise((resolve) => { + const ceremonyAbort = new AbortController(); let settled = false; let timer: ReturnType | undefined; const finish = (ok: boolean, status: DesktopIdentityStatus) => { if (settled) return; settled = true; + if (!ok) ceremonyAbort.abort(); if (timer) clearTimeout(timer); if (this.activeWindow === identityWindow) this.activeWindow = null; if (this.isCeremonyCurrent(generation)) this.setStatus(status); @@ -651,7 +657,7 @@ export class DesktopIdentityBroker { return; } void this.trackSessionCopy( - this.copyTargetSession(app, generation), + this.copyTargetSession(app, generation, ceremonyAbort.signal), ).then( () => { if (!this.isCeremonyCurrent(generation)) { @@ -661,7 +667,11 @@ export class DesktopIdentityBroker { this.options.reloadApp(app); finish(true, "signed-in"); }, - () => finish(false, "failed"), + () => { + if (ceremonyAbort.signal.aborted) return; + this.recoverFromSessionCopyFailure(app, generation); + finish(false, "failed"); + }, ); return; } @@ -700,52 +710,154 @@ export class DesktopIdentityBroker { private async copyTargetSession( app: DesktopIdentityApp, generation: number, + signal?: AbortSignal, ): Promise { - this.assertCeremonyCurrent(generation); - const sourceCookies = await this.options.identitySession.cookies.get({ - url: app.origin, - }); - this.assertCeremonyCurrent(generation); + this.assertCeremonyActive(generation, signal); const allowed = new Set(app.cookieNames); - const cookies = sourceCookies.filter((cookie) => allowed.has(cookie.name)); + const deadline = + Date.now() + + (this.options.sessionCookieWaitMs ?? DEFAULT_SESSION_COOKIE_WAIT_MS); + let cookies: Electron.Cookie[] = []; + do { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + const sourceCookies = await this.readIdentityCookies( + app.origin, + remainingMs, + signal, + ); + this.assertCeremonyActive(generation, signal); + cookies = sourceCookies.filter((cookie) => allowed.has(cookie.name)); + if (cookies.length > 0 || Date.now() >= deadline) break; + await this.waitForCookiePoll( + Math.min(SESSION_COOKIE_POLL_INTERVAL_MS, deadline - Date.now()), + signal, + ); + this.assertCeremonyActive(generation, signal); + } while (true); if (cookies.length === 0) throw new Error("Missing app session cookie"); - for (const cookieName of app.cookieNames) { - this.assertCeremonyCurrent(generation); - await app.session.cookies.remove(app.origin, cookieName).catch(() => {}); - } - for (const cookie of cookies) { - this.assertCeremonyCurrent(generation); - await app.session.cookies.set({ - url: app.origin, - name: cookie.name, - value: cookie.value, - path: cookie.path || "/", - httpOnly: cookie.httpOnly, - secure: cookie.secure, - sameSite: cookie.sameSite, - ...(cookie.expirationDate - ? { expirationDate: cookie.expirationDate } - : {}), - }); - if (!this.isCeremonyCurrent(generation)) { + const writtenCookieNames: string[] = []; + try { + for (const cookieName of app.cookieNames) { + this.assertCeremonyActive(generation, signal); await app.session.cookies - .remove(app.origin, cookie.name) + .remove(app.origin, cookieName) .catch(() => {}); - this.assertCeremonyCurrent(generation); } + for (const cookie of cookies) { + this.assertCeremonyActive(generation, signal); + await app.session.cookies.set({ + url: app.origin, + name: cookie.name, + value: cookie.value, + path: cookie.path || "/", + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + ...(cookie.expirationDate + ? { expirationDate: cookie.expirationDate } + : {}), + }); + writtenCookieNames.push(cookie.name); + this.assertCeremonyActive(generation, signal); + } + + if (!app.identityAuthority) { + for (const cookie of cookies) { + this.assertCeremonyActive(generation, signal); + await this.options.identitySession.cookies + .remove(app.origin, cookie.name) + .catch(() => {}); + this.assertCeremonyActive(generation, signal); + } + } + this.assertCeremonyActive(generation, signal); + } catch (error) { + if (!this.isCeremonyCurrent(generation) || signal?.aborted) { + await Promise.all( + writtenCookieNames.map((cookieName) => + app.session.cookies.remove(app.origin, cookieName).catch(() => {}), + ), + ); + } + throw error; } + } - if (!app.identityAuthority) { - for (const cookie of cookies) { - this.assertCeremonyCurrent(generation); - await this.options.identitySession.cookies - .remove(app.origin, cookie.name) - .catch(() => {}); + private async readIdentityCookies( + origin: string, + timeoutMs: number, + signal?: AbortSignal, + ): Promise { + let timeout: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const stopped = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Identity cookie read timed out")), + timeoutMs, + ); + if (signal) { + onAbort = () => + reject(new Error("Desktop identity ceremony was cancelled")); + signal.addEventListener("abort", onAbort, { once: true }); } + }); + try { + return await Promise.race([ + this.options.identitySession.cookies.get({ url: origin }), + stopped, + ]); + } finally { + if (timeout) clearTimeout(timeout); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); } } + private async waitForCookiePoll( + delayMs: number, + signal?: AbortSignal, + ): Promise { + if (delayMs <= 0) return; + await new Promise((resolve, reject) => { + let settled = false; + let timeout: ReturnType | undefined; + const cleanup = () => { + if (timeout) clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + }; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Desktop identity ceremony was cancelled")); + }; + timeout = setTimeout(finish, delayMs); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + private recoverFromSessionCopyFailure( + app: DesktopIdentityApp, + generation: number, + ): void { + if (!this.isCeremonyCurrent(generation)) return; + console.warn("[desktop-identity] target session transfer failed", { + appId: app.id, + }); + this.options.reloadApp(app); + } + private closeActiveWindow(): void { const active = this.activeWindow; this.activeWindow = null; @@ -777,6 +889,13 @@ export class DesktopIdentityBroker { } } + private assertCeremonyActive(generation: number, signal?: AbortSignal): void { + this.assertCeremonyCurrent(generation); + if (signal?.aborted) { + throw new Error("Desktop identity ceremony was cancelled"); + } + } + private setStatus(status: DesktopIdentityStatus): void { this.status = status; this.options.onStatus?.(status); diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 45d6c777ce..336e4bcc99 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -174,6 +174,13 @@ acceptance-state: - the packaged canary version family now selects Agent Native SSO Canary as its userData directory before Sentry or logger initialization and aborts launch if that isolated profile cannot be established, while stable Desktop retains its existing profile - canary-profile fix verification: all 236 Desktop tests passed; Desktop TypeScript, production build, formatting, and git diff checks passed - the startup regression suite proves profile creation and selection precede Sentry and logger initialization, packaged isolation failure aborts before either consumer starts, stable Desktop leaves its profile unchanged, and development retains its recoverable fallback + - exact-head required CI run 30702326735 and signed canary run 30702325684 passed on 7535935d40475d57d715f323129a577d9b301838; canary .20 matched its manifest and passed strict codesign, stapler, Gatekeeper, bundle identity, version, signer, and arm64 checks + - independent native preflight on canary .20 proved its isolated userData profile, unchanged stable profile, disabled updater, and real quit/relaunch behavior; H1 passed the signed-out Settings/status boundary and H2 passed ordinary Mail front-door routing into Dispatch with app=mail + - after Alice completed the private Google step, the ceremony returned to canonical Mail but the underlying app remained on its pre-auth loading fallback; no post-completion Mail reload appeared in the redacted Desktop log, so H4 failed and H5-H10 were not started + - the failed production window was rolled back Mail first and Dispatch second to known-good main deploys 6a6ddd1e966031000859bd7e and 6a6ddd1e8eef7d00084f719e; both identity routes returned baseline 401, both sites were unlocked, all Canary/updater processes were stopped, and the stable profile mtime remained unchanged + - the completion handler can observe Mail's redirect before Chromium commits its Set-Cookie result; the broker now waits up to two seconds for only the exact allowlisted target cookie, preserves cancellation fencing, and reloads to a recoverable app state with a value-free diagnostic if transfer still fails + - cookie-commit repair verification: all 240 Desktop tests passed; Desktop TypeScript, production build, formatting, and git diff checks passed + - final independent read-only re-review confirmed the prior cancellation and hung-cookie-read findings are resolved, the new close/timeout/read-stall coverage is sufficient, and no remaining source-level correctness or credential-handling defect was found implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -186,9 +193,10 @@ acceptance-state: - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption - the ordinary sign-in entry in a canonical first-party app starts the app-targeted workspace ceremony; Settings exposes status and workspace sign-out but no separate authority sign-in command + - bounded target-cookie commit synchronization before isolated app-session transfer, with value-free diagnostics and recoverable reload on failure blockers: - - the canary-profile fix must be independently reviewed, pushed, and pass exact-head CI plus a newly signed canary before native Google sign-in from an ordinary app front door can begin - - the short Mail and Dispatch candidate publication window must then complete same-account continuity, restart, sign-out, account-switch, isolation, provider-separation, and hostile-flow acceptance before Work can be complete + - the cookie-commit repair must pass exact-head CI and a newly signed isolated canary before another production window + - a renewed short Mail and Dispatch window must prove H1-H10, including same-account continuity, restart, provider separation, workspace sign-out, and safe cancellation/concurrency; the prior H1-H2 evidence is informative but cannot complete acceptance for the repaired artifact last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: allowed: @@ -216,11 +224,11 @@ return-to-shape: risk-strategy: unchanged system-ready, production validation before merge replacement-acceptance-story: desktop-first-party-sso-front-door-v2 approved production-safety-preflight: - verified-at: 2026-08-01T09:05:00-04:00 + verified-at: 2026-08-01T11:24:00-04:00 mail: normal unlocked auto-publishing main deploy 6a6ddd1e966031000859bd7e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity route baseline 401, root normal 302 dispatch: normal unlocked auto-publishing main deploy 6a6ddd1e8eef7d00084f719e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity authorize baseline 401 local: no SSO Canary, ShipIt, or Squirrel process remains; stable Desktop untouched -ledger-revision: desktop-sso-work-r20 +ledger-revision: desktop-sso-work-r21 status: active ``` From baf8115e7846e542526b88e9a6778cfc338cfa5b Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 11:59:56 -0400 Subject: [PATCH 24/31] Require committed Desktop SSO completion --- .../src/main/desktop-identity.spec.ts | 274 +++++++++++++++++- .../desktop-app/src/main/desktop-identity.ts | 74 +++-- .../desktop-app/src/renderer/App.spec.tsx | 92 ++++++ plans/desktop-sso-intent/work-ledger.md | 41 ++- 4 files changed, 420 insertions(+), 61 deletions(-) create mode 100644 packages/desktop-app/src/renderer/App.spec.tsx diff --git a/packages/desktop-app/src/main/desktop-identity.spec.ts b/packages/desktop-app/src/main/desktop-identity.spec.ts index 3057f21f56..f456fc6cf6 100644 --- a/packages/desktop-app/src/main/desktop-identity.spec.ts +++ b/packages/desktop-app/src/main/desktop-identity.spec.ts @@ -458,15 +458,20 @@ describe("DesktopIdentityBroker", () => { const nonce = new URL(loadedUrl).searchParams.get("return")!; const completion = new URL(nonce, app.origin).toString(); + const redirectHandler = webContents.on.mock.calls.find( + ([event]) => event === "will-redirect", + )?.[1]; const navigationHandler = webContents.on.mock.calls.find( - ([event]) => event === "will-navigate", + ([event]) => event === "did-navigate", )?.[1]; const preventDefault = vi.fn(); - navigationHandler({ preventDefault }, completion); + redirectHandler({ preventDefault }, completion); + expect(identityCookies.get).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + navigationHandler({}, completion, 200, "OK"); await expect(first).resolves.toBe(true); expect(identityCookies.get).toHaveBeenCalledTimes(2); - expect(preventDefault).toHaveBeenCalled(); expect(app.session.cookies.set).toHaveBeenCalledTimes(1); expect(app.session.cookies.set).toHaveBeenCalledWith( expect.objectContaining({ @@ -482,6 +487,110 @@ describe("DesktopIdentityBroker", () => { expect(closedListener).toBeDefined(); }); + it("requires an authenticated committed completion before copying", async () => { + const app = appFixture(); + const identityCookies = cookieStore([ + sessionCookie("an_session_mail", app.origin), + ]); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const reloadApp = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + navigationHandler({}, completion, 401, "Unauthorized"); + + await expect(ceremony).resolves.toBe(false); + expect(identityCookies.get).not.toHaveBeenCalled(); + expect(app.session.cookies.set).not.toHaveBeenCalled(); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("failed"); + expect(warn).toHaveBeenCalledWith( + "[desktop-identity] authenticated completion failed", + { appId: "mail", statusCode: 401 }, + ); + expect(JSON.stringify(warn.mock.calls)).not.toContain("session-value"); + warn.mockRestore(); + }); + + it("ignores a committed completion with the wrong nonce", async () => { + const app = appFixture(); + const identityCookies = cookieStore([ + sessionCookie("an_session_mail", app.origin), + ]); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const wrongCompletion = new URL( + `${DESKTOP_IDENTITY_COMPLETE_PATH}?nonce=wrong`, + app.origin, + ).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + navigationHandler({}, wrongCompletion, 200, "OK"); + expect(identityCookies.get).not.toHaveBeenCalled(); + closedListener?.(); + + await expect(ceremony).resolves.toBe(false); + expect(app.session.cookies.set).not.toHaveBeenCalled(); + }); + it("recovers from a missing target cookie without logging session details", async () => { const app = appFixture(); const webContents = { @@ -517,9 +626,9 @@ describe("DesktopIdentityBroker", () => { const returnPath = new URL(loadedUrl).searchParams.get("return")!; const completion = new URL(returnPath, app.origin).toString(); const navigationHandler = webContents.on.mock.calls.find( - ([event]) => event === "will-navigate", + ([event]) => event === "did-navigate", )?.[1]; - navigationHandler({ preventDefault: vi.fn() }, completion); + navigationHandler({}, completion, 200, "OK"); await expect(ceremony).resolves.toBe(false); expect(reloadApp).toHaveBeenCalledWith(app); @@ -570,9 +679,9 @@ describe("DesktopIdentityBroker", () => { const returnPath = new URL(loadedUrl).searchParams.get("return")!; const completion = new URL(returnPath, app.origin).toString(); const navigationHandler = webContents.on.mock.calls.find( - ([event]) => event === "will-navigate", + ([event]) => event === "did-navigate", )?.[1]; - navigationHandler({ preventDefault: vi.fn() }, completion); + navigationHandler({}, completion, 200, "OK"); await vi.waitFor(() => expect(identityCookies.get).toHaveBeenCalled()); closedListener?.(); @@ -629,9 +738,9 @@ describe("DesktopIdentityBroker", () => { const returnPath = new URL(loadedUrl).searchParams.get("return")!; const completion = new URL(returnPath, app.origin).toString(); const navigationHandler = webContents.on.mock.calls.find( - ([event]) => event === "will-navigate", + ([event]) => event === "did-navigate", )?.[1]; - navigationHandler({ preventDefault: vi.fn() }, completion); + navigationHandler({}, completion, 200, "OK"); await expect(ceremony).resolves.toBe(false); await new Promise((resolve) => setTimeout(resolve, 40)); @@ -679,9 +788,9 @@ describe("DesktopIdentityBroker", () => { const returnPath = new URL(loadedUrl).searchParams.get("return")!; const completion = new URL(returnPath, app.origin).toString(); const navigationHandler = webContents.on.mock.calls.find( - ([event]) => event === "will-navigate", + ([event]) => event === "did-navigate", )?.[1]; - navigationHandler({ preventDefault: vi.fn() }, completion); + navigationHandler({}, completion, 200, "OK"); await expect(ceremony).resolves.toBe(false); expect(reloadApp).toHaveBeenCalledWith(app); @@ -699,7 +808,21 @@ describe("DesktopIdentityBroker", () => { const returnPath = new URL(loginUrl).searchParams.get("return")!; return new URL(returnPath, dispatch.origin).toString(); }); - const createWindow = vi.fn(); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const createWindow = vi.fn(() => identityWindow as never); const reloadApp = vi.fn(); const broker = new DesktopIdentityBroker({ identitySession: { @@ -713,14 +836,23 @@ describe("DesktopIdentityBroker", () => { clearLocalBroker: vi.fn(), }); - await expect(broker.ensureAppSession(dispatch.id)).resolves.toBe(true); + const ceremony = broker.ensureAppSession(dispatch.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); const loginUrl = String(resolveLoginRedirect.mock.calls[0]?.[0]); const returnPath = new URL(loginUrl).searchParams.get("return")!; const completion = new URL(returnPath, dispatch.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + expect(identityCookies.get).not.toHaveBeenCalled(); + navigationHandler({}, completion, 200, "OK"); + + await expect(ceremony).resolves.toBe(true); expect(new URL(loginUrl).pathname).toBe("/_agent-native/identity/login"); expect(new URL(completion).pathname).toBe(DESKTOP_IDENTITY_COMPLETE_PATH); - expect(createWindow).not.toHaveBeenCalled(); + expect(createWindow).toHaveBeenCalledOnce(); + expect(identityWindow.loadURL).toHaveBeenCalledWith(completion); expect(dispatch.session.cookies.set).toHaveBeenCalledTimes(1); expect(dispatch.session.cookies.set).toHaveBeenCalledWith( expect.objectContaining({ @@ -1106,9 +1238,9 @@ describe("DesktopIdentityBroker", () => { const returnPath = new URL(loadedUrl).searchParams.get("return")!; const completion = new URL(returnPath, app.origin).toString(); const navigationHandler = webContents.on.mock.calls.find( - ([event]) => event === "will-navigate", + ([event]) => event === "did-navigate", )?.[1]; - navigationHandler({ preventDefault: vi.fn() }, completion); + navigationHandler({}, completion, 200, "OK"); await vi.waitFor(() => expect(targetCookies.set).toHaveBeenCalledOnce()); let signOutResolved = false; @@ -1135,4 +1267,114 @@ describe("DesktopIdentityBroker", () => { expect(createWindow).toHaveBeenCalledOnce(); expect(reloadApp).toHaveBeenCalledTimes(1); }); + + it("drains cancelled cookie cleanup before starting an immediate reauthentication", async () => { + const app = appFixture(); + const identityCookies = cookieStore([ + { + name: "an_session_mail", + value: "example-session-value", + domain: "mail.agent-native.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + session: true, + sameSite: "lax", + }, + ]); + const firstCookieWrite = deferred(); + const targetCookies = cookieStore(); + targetCookies.set + .mockImplementationOnce(async () => firstCookieWrite.promise) + .mockImplementation(async () => {}); + app.session = { + cookies: targetCookies, + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } as unknown as Electron.Session; + const windows: Array<{ + loadedUrl: string; + webContents: { + on: ReturnType; + setWindowOpenHandler: ReturnType; + }; + closed: () => void; + }> = []; + const createWindow = vi.fn(() => { + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + let closed = () => {}; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + windows[windows.length - 1]!.loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closed = listener; + windows[windows.length - 1]!.closed = listener; + }), + }; + windows.push({ + loadedUrl, + webContents, + closed: () => closed(), + }); + return identityWindow as never; + }); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const firstCeremony = broker.signIn(app.id); + await vi.waitFor(() => { + expect(windows).toHaveLength(1); + expect(windows[0]!.loadedUrl).not.toBe(""); + }); + const firstReturnPath = new URL(windows[0]!.loadedUrl).searchParams.get( + "return", + )!; + const firstCompletion = new URL(firstReturnPath, app.origin).toString(); + const firstNavigation = windows[0]!.webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + firstNavigation({}, firstCompletion, 200, "OK"); + await vi.waitFor(() => expect(targetCookies.set).toHaveBeenCalledOnce()); + + windows[0]!.closed(); + await expect(firstCeremony).resolves.toBe(false); + const secondCeremony = broker.signIn(app.id); + await Promise.resolve(); + expect(createWindow).toHaveBeenCalledOnce(); + + firstCookieWrite.resolve(); + await vi.waitFor(() => expect(createWindow).toHaveBeenCalledTimes(2)); + expect(targetCookies.remove).toHaveBeenCalledWith( + app.origin, + "an_session_mail", + ); + const secondReturnPath = new URL(windows[1]!.loadedUrl).searchParams.get( + "return", + )!; + const secondCompletion = new URL(secondReturnPath, app.origin).toString(); + const secondNavigation = windows[1]!.webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + secondNavigation({}, secondCompletion, 200, "OK"); + + await expect(secondCeremony).resolves.toBe(true); + expect(targetCookies.set).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/desktop-app/src/main/desktop-identity.ts b/packages/desktop-app/src/main/desktop-identity.ts index 3b5ac3ba06..33aa1d4841 100644 --- a/packages/desktop-app/src/main/desktop-identity.ts +++ b/packages/desktop-app/src/main/desktop-identity.ts @@ -266,6 +266,7 @@ export class DesktopIdentityBroker { const generation = this.ceremonyGeneration; const operation = this.queue.then(async () => { await this.signOutOperation; + await this.waitForActiveSessionCopies(); return this.runCeremony(appId, generation); }); this.queue = operation.then( @@ -592,29 +593,18 @@ export class DesktopIdentityBroker { return false; } initialUrl = redirectUrl; - if (isDesktopIdentityCompletion(initialUrl, app, nonce)) { - try { - await this.trackSessionCopy(this.copyTargetSession(app, generation)); - if (!this.isCeremonyCurrent(generation)) return false; + if (!isDesktopIdentityCompletion(initialUrl, app, nonce)) { + const authorityApp = this.options.resolveApp("dispatch"); + if ( + !authorityApp || + !isDesktopIdentityAuthorizeNavigation(initialUrl, authorityApp, app) + ) { + this.unsupportedAppIds.add(app.id); + this.setStatus("failed"); this.options.reloadApp(app); - this.setStatus("signed-in"); - return true; - } catch { - this.recoverFromSessionCopyFailure(app, generation); - if (this.isCeremonyCurrent(generation)) this.setStatus("failed"); return false; } } - const authorityApp = this.options.resolveApp("dispatch"); - if ( - !authorityApp || - !isDesktopIdentityAuthorizeNavigation(initialUrl, authorityApp, app) - ) { - this.unsupportedAppIds.add(app.id); - this.setStatus("failed"); - this.options.reloadApp(app); - return false; - } } if (!this.isCeremonyCurrent(generation)) return false; @@ -637,6 +627,7 @@ export class DesktopIdentityBroker { return new Promise((resolve) => { const ceremonyAbort = new AbortController(); let settled = false; + let completionStarted = false; let timer: ReturnType | undefined; const finish = (ok: boolean, status: DesktopIdentityStatus) => { if (settled) return; @@ -651,11 +642,43 @@ export class DesktopIdentityBroker { const inspectNavigation = (event: Electron.Event, url: string) => { if (isDesktopIdentityCompletion(url, app, nonce)) { + return; + } + if ( + this.options.handleOAuthNavigation?.(url, identityWindow.webContents) + ) { event.preventDefault(); + } + }; + + identityWindow.webContents.on("will-navigate", inspectNavigation); + identityWindow.webContents.on("will-redirect", (event, url) => + inspectNavigation(event, url), + ); + identityWindow.webContents.on( + "did-navigate", + (_event, url, httpResponseCode) => { + if ( + settled || + completionStarted || + !isDesktopIdentityCompletion(url, app, nonce) + ) { + return; + } + completionStarted = true; if (!this.isCeremonyCurrent(generation)) { finish(false, "sign-in-required"); return; } + if (httpResponseCode !== 200) { + console.warn("[desktop-identity] authenticated completion failed", { + appId: app.id, + statusCode: httpResponseCode, + }); + this.options.reloadApp(app); + finish(false, "failed"); + return; + } void this.trackSessionCopy( this.copyTargetSession(app, generation, ceremonyAbort.signal), ).then( @@ -673,18 +696,7 @@ export class DesktopIdentityBroker { finish(false, "failed"); }, ); - return; - } - if ( - this.options.handleOAuthNavigation?.(url, identityWindow.webContents) - ) { - event.preventDefault(); - } - }; - - identityWindow.webContents.on("will-navigate", inspectNavigation); - identityWindow.webContents.on("will-redirect", (event, url) => - inspectNavigation(event, url), + }, ); identityWindow.webContents.setWindowOpenHandler(({ url }) => this.options.handleWindowOpen diff --git a/packages/desktop-app/src/renderer/App.spec.tsx b/packages/desktop-app/src/renderer/App.spec.tsx new file mode 100644 index 0000000000..01fae4f859 --- /dev/null +++ b/packages/desktop-app/src/renderer/App.spec.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom + +import { DESKTOP_DEFAULT_APPS } from "@shared/app-registry"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sonner", () => ({ + Toaster: () => null, + toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }), +})); + +vi.mock("./components/AppSettings.js", () => ({ + default: () =>
App Settings
, + AddAppDialog: () => null, + AppEditForm: () => null, +})); + +vi.mock("./components/AppWebview.js", async () => { + const react = await import("react"); + return { + default: react.forwardRef(() => ( +
Still loading
+ )), + }; +}); + +vi.mock("./components/CodeAgentsHub.js", () => ({ default: () => null })); +vi.mock("./components/TabBar.js", () => ({ default: () => null })); +vi.mock("./components/UpdatePrompt.js", () => ({ default: () => null })); +vi.mock("./components/UpdateIndicator.js", () => ({ + UpdateIndicator: () => null, +})); + +import App from "./App.js"; + +describe("Desktop shell Settings boundary", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + window.electronAPI = { + appConfig: { + load: vi.fn(async () => [ + { ...DESKTOP_DEFAULT_APPS.find((app) => app.id === "mail")! }, + ]), + }, + frame: { + load: vi.fn(async () => ({ + enabled: false, + showCodeTab: false, + mode: "prod" as const, + })), + }, + setActiveApp: vi.fn(), + } as unknown as ElectronAPI; + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("opens Settings while the active app is still loading", async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + await Promise.resolve(); + }); + + expect( + container.querySelector('[data-testid="slow-webview"]'), + ).not.toBeNull(); + const settings = container.querySelector( + 'button[aria-label="Settings"]', + ); + expect(settings).not.toBeNull(); + + act(() => settings?.click()); + + expect(container.querySelector('[role="dialog"]')?.textContent).toBe( + "App Settings", + ); + }); +}); diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 336e4bcc99..b6fcaaa7c9 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -2,7 +2,7 @@ ```yaml stage: work -authority-source: "Alice: Yes, you can work it, approving desktop-first-party-sso-front-door-v2." +authority-source: "Alice invoked /work on 2026-08-02 against desktop-sso-diagnosis-plan-r1, then required an explicit stop before the next production test." authorized-scope: repositories: - BuilderIO/agent-native @@ -26,8 +26,8 @@ write-targets: - .agents/skills/authentication/SKILL.md - plans/desktop-sso-intent/work-ledger.md governing-artifact: - path: plans/desktop-sso-intent/work-ledger.md - revision: desktop-sso-work-r20 + path: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary diagnosis and replacement work plan 2026-08-02.md + revision: desktop-sso-diagnosis-plan-r1 architecture-fingerprint: outcome: one Agent Native workspace sign-in begun from the ordinary front door of any canonical first-party app, followed by silent app-local federation shipping-surfaces: @@ -55,10 +55,10 @@ architecture-fingerprint: constituency: supervised pre-merge canary testers durable-destination: exact candidate and rollback receipts recorded in the ledger, with canonical production restored after the test integration-action: deploy - governing-architecture: Electron main keeps one dedicated Dispatch identity partition, intercepts the ordinary sign-in entry of an eligible canonical app, and uses existing app-local federation; Settings is status, sign-out, and recovery rather than the primary identity front door; local development retains its loopback file broker; custom apps and Builder Connect remain outside the boundary + governing-architecture: Electron main keeps one dedicated Dispatch identity partition, intercepts the ordinary sign-in entry of an eligible canonical app, and lets the exact nonce-bound authenticated completion navigation commit with HTTP 200 before copying only that app's allowlisted session; Settings remains status, sign-out, and recovery; local development retains its loopback file broker; custom apps and Builder Connect remain outside the boundary acceptance-story: - id: desktop-first-party-sso-front-door-v2 - summary: signing in normally from any eligible canonical first-party app establishes the shared Agent Native workspace identity, returns to that initiating app without exposing Dispatch home, and lets every later canonical app open as the same verified account while preserving app-local sessions, databases, authorization, and data isolation + id: desktop-first-party-sso-committed-completion-v3 + summary: signing in normally from any eligible canonical first-party app commits an authenticated nonce-bound completion before Desktop transfers that app's session, returns to the initiating app without exposing Dispatch home, and lets every later canonical app open as the same verified account while preserving app-local sessions, databases, authorization, and data isolation required-assertions: - signed packaged Desktop and real canary deployment pass before merge - the ordinary sign-in entry in each enabled canonical first-party app, including Dispatch, can initiate the shared workspace ceremony and returns to the initiating app without using Dispatch home as a completion screen @@ -66,6 +66,7 @@ architecture-fingerprint: - every enabled canonical first-party app resolves the correct existing account and data without another credential ceremony - restart, workspace sign-out, account switch, standalone browser, custom app, and local-development behavior pass - hostile redirect, nonce, origin, cookie, concurrency, cancellation, and logging cases fail closed + - the Settings H1 miss is deterministically classified before production, and Settings code changes only if the failure is reproduced and localized - Agent Native identity sign-in remains separate from app-specific provider connection and consent - Builder internal and Builder credentials do not participate risk-strategy: @@ -98,7 +99,7 @@ architecture-grounding: - workspace sign-in means Agent Native identity federation, not provider connection sharing - app front door means the app's ordinary sign-in entry, not a hidden Dispatch account page - Settings account card is a secondary status, sign-out, and recovery surface - smallest-compatible-delta: remove the Settings-only authority sign-in command, make an eligible canonical app's ordinary sign-in entry the sole workspace front door, reflect authority status and workspace sign-out in Settings, and retain Dispatch's generic sign-in only as the identity-provider step inside the app-led ceremony + smallest-compatible-delta: keep the eligible app's ordinary sign-in entry as the workspace front door, allow only its exact nonce-bound completion navigation to commit, require authenticated HTTP 200 before cookie transfer, and leave Settings unchanged unless deterministic evidence localizes its H1 failure deferred-capabilities: - custom and third-party app federation - shared provider OAuth grants or automatic provider-scope expansion @@ -109,6 +110,8 @@ architecture-grounding: - pre-fix source routed Settings directly to signInAuthority(dispatch) and used Dispatch root as its completion observation - current source already intercepts exact sign-in navigation for eligible canonical apps and completes app-local federation in the dedicated identity partition - Core documentation defines federation as happening at the app front door and preserves app-local sessions + - canary .21 completed Alice's private Google ceremony but logged a value-free Mail session-transfer failure after Desktop cancelled the announced completion redirect and polled for two seconds + - Electron navigation ordering makes did-navigate with its response code the first committed main-frame completion boundary inferences: - the Clip's immediate root redirect is consistent with a pre-existing Dispatch session in the identity partition, but the Clip does not expose cookie or IPC status evidence - Dispatch self-federation is source-supported but still requires current signed-runtime acceptance @@ -117,7 +120,7 @@ product-boundary-gates: agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure acceptance-state: status: pending - summary: Alice approved the app-front-door replacement story; current main is integrated, the branch canary now has a fail-closed profile distinct from stable Desktop, and exact-head CI, signed-canary, and native acceptance must be regenerated while production remains on normal unlocked main deployments + summary: Alice approved committed-completion v3; Work is active locally on PR #2290, while production must remain normal until Alice is notified and the task stops for the supervised canary verified: - Core identity protocol suite: 210 tests passed - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed @@ -181,6 +184,12 @@ acceptance-state: - the completion handler can observe Mail's redirect before Chromium commits its Set-Cookie result; the broker now waits up to two seconds for only the exact allowlisted target cookie, preserves cancellation fencing, and reloads to a recoverable app state with a value-free diagnostic if transfer still fails - cookie-commit repair verification: all 240 Desktop tests passed; Desktop TypeScript, production build, formatting, and git diff checks passed - final independent read-only re-review confirmed the prior cancellation and hung-cookie-read findings are resolved, the new close/timeout/read-stall coverage is sufficient, and no remaining source-level correctness or credential-handling defect was found + - committed-completion repair verification: all 244 Desktop tests across 26 files passed; Desktop TypeScript and production build passed; formatting and git diff checks passed + - focused shared-seam verification: Core identity protocol and store 27 tests, Dispatch primary-auth forwarding 1 test, and Dispatch identity library 24 tests passed + - a renderer-level regression mounted the real Desktop App and Sidebar around an active slow-loading webview and proved the Settings dialog opens; the prior H1 symptom is therefore not reproduced in the renderer event path and no Settings product code changed + - Electron 41.9 runtime proof observed an exact completion navigation commit with HTTP 200 and one obvious-fake target cookie becoming available afterward; no task Electron or updater process remained + - completion regressions prove will-redirect is not cancelled, no cookie read begins before did-navigate with HTTP 200, 401 completion fails closed without reading or copying cookies, a wrong nonce is ignored, direct Dispatch completion uses the same committed boundary, and diagnostics contain app id plus status only + - independent review found and the implementation repaired a cancellation race: every new ceremony now drains tracked prior session-copy cleanup before opening, and immediate reauthentication cannot lose its new target cookie to a late cleanup; re-review found no remaining actionable source defect implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -193,10 +202,11 @@ acceptance-state: - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption - the ordinary sign-in entry in a canonical first-party app starts the app-targeted workspace ceremony; Settings exposes status and workspace sign-out but no separate authority sign-in command - - bounded target-cookie commit synchronization before isolated app-session transfer, with value-free diagnostics and recoverable reload on failure + - exact nonce-bound app-local completion must commit with HTTP 200 before isolated app-session transfer, followed only by bounded allowlisted-cookie synchronization and value-free diagnostics blockers: - - the cookie-commit repair must pass exact-head CI and a newly signed isolated canary before another production window + - the committed-completion repair must pass exact-head CI and a newly signed isolated canary before another production window; local runtime and test proof are complete - a renewed short Mail and Dispatch window must prove H1-H10, including same-account continuity, restart, provider separation, workspace sign-out, and safe cancellation/concurrency; the prior H1-H2 evidence is informative but cannot complete acceptance for the repaired artifact + - Alice requires an explicit notification and agent stop before any next production test or Netlify production mutation last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 deployment-boundary: allowed: @@ -210,8 +220,8 @@ deployment-boundary: - enabling arbitrary preview hosts, custom apps, or Builder credentials vault-brief: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary implementation plan 2026-07-21.md return-to-shape: - banner: resolved by Alice's explicit Work approval - invalidated-field: acceptance story + banner: resolved by Alice's explicit 2026-08-02 Work approval + invalidated-field: governing architecture and acceptance story old-fingerprint: outcome: one Desktop workspace identity ceremony followed by silent app-local federation governing-architecture: dedicated Dispatch identity partition plus app-local federation, with Settings able to start a direct authority ceremony @@ -222,13 +232,16 @@ return-to-shape: governing-architecture: dedicated Dispatch identity partition plus existing app-targeted federation; Settings is secondary status, sign-out, and recovery and never exposes Dispatch home as completion acceptance-story: desktop-first-party-sso-front-door-v2 risk-strategy: unchanged system-ready, production validation before merge - replacement-acceptance-story: desktop-first-party-sso-front-door-v2 approved + replacement-acceptance-story: desktop-first-party-sso-committed-completion-v3 approved production-safety-preflight: verified-at: 2026-08-01T11:24:00-04:00 mail: normal unlocked auto-publishing main deploy 6a6ddd1e966031000859bd7e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity route baseline 401, root normal 302 dispatch: normal unlocked auto-publishing main deploy 6a6ddd1e8eef7d00084f719e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity authorize baseline 401 local: no SSO Canary, ShipIt, or Squirrel process remains; stable Desktop untouched -ledger-revision: desktop-sso-work-r21 +work-constraints: + production-test-custody: notify Alice and stop before any production canary or Netlify production mutation + pr-cleanup: remove agent-authored progress-comment clutter after preserving durable truth locally; retain human review and concise reviewer-facing information +ledger-revision: desktop-sso-work-r24 status: active ``` From 6426bcad3d98bda7e13e774137d257886f675cab Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:01:12 -0400 Subject: [PATCH 25/31] Record Desktop SSO verification receipt --- plans/desktop-sso-intent/work-ledger.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index b6fcaaa7c9..684c34340a 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -207,7 +207,8 @@ acceptance-state: - the committed-completion repair must pass exact-head CI and a newly signed isolated canary before another production window; local runtime and test proof are complete - a renewed short Mail and Dispatch window must prove H1-H10, including same-account continuity, restart, provider separation, workspace sign-out, and safe cancellation/concurrency; the prior H1-H2 evidence is informative but cannot complete acceptance for the repaired artifact - Alice requires an explicit notification and agent stop before any next production test or Netlify production mutation - last-land-packet: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5062742844 + latest-production-result: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5158731013 + pr-cleanup-receipt: ten archived intermediate progress comments were deleted on 2026-08-02; human discussion, all review threads, concise finding-resolution replies, and the latest production failure/rollback result remain deployment-boundary: allowed: - branch-scoped GitHub Actions macOS canary build with publish disabled @@ -241,7 +242,7 @@ production-safety-preflight: work-constraints: production-test-custody: notify Alice and stop before any production canary or Netlify production mutation pr-cleanup: remove agent-authored progress-comment clutter after preserving durable truth locally; retain human review and concise reviewer-facing information -ledger-revision: desktop-sso-work-r24 +ledger-revision: desktop-sso-work-r25 status: active ``` From d6607b41c64451e82cf1ab6f9214759af9bfe86c Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:30:33 -0400 Subject: [PATCH 26/31] Bound Desktop quit cleanup --- .../multi-frontier-app-integration.spec.ts | 40 +++++++++++++++++-- .../main/multi-frontier-app-integration.ts | 24 +++++++---- plans/desktop-sso-intent/work-ledger.md | 8 +++- 3 files changed, 60 insertions(+), 12 deletions(-) diff --git a/packages/desktop-app/src/main/multi-frontier-app-integration.spec.ts b/packages/desktop-app/src/main/multi-frontier-app-integration.spec.ts index 326da11e90..f5836405db 100644 --- a/packages/desktop-app/src/main/multi-frontier-app-integration.spec.ts +++ b/packages/desktop-app/src/main/multi-frontier-app-integration.spec.ts @@ -89,15 +89,49 @@ describe("multi-frontier app integration", () => { expect(guard(event)).toBe(true); expect(guard(event)).toBe(true); + await Promise.resolve(); expect(dispose).toHaveBeenCalledOnce(); settle(); - await Promise.resolve(); - await Promise.resolve(); + await vi.waitFor(() => expect(reissueQuit).toHaveBeenCalledOnce()); + expect(guard(event)).toBe(false); + }); - expect(reissueQuit).toHaveBeenCalledOnce(); + it("reissues quit when disposal throws synchronously", async () => { + const reissueQuit = vi.fn(); + const guard = createMultiFrontierQuitGuard({ + dispose: vi.fn(() => { + throw new Error("dispose failed"); + }), + reissueQuit, + }); + const event = { preventDefault: vi.fn() }; + + expect(guard(event)).toBe(true); + await vi.waitFor(() => expect(reissueQuit).toHaveBeenCalledOnce()); expect(guard(event)).toBe(false); }); + it("bounds disposal so application quit cannot hang indefinitely", async () => { + vi.useFakeTimers(); + try { + const reissueQuit = vi.fn(); + const guard = createMultiFrontierQuitGuard({ + dispose: vi.fn(() => new Promise(() => undefined)), + reissueQuit, + disposeTimeoutMs: 25, + }); + const event = { preventDefault: vi.fn() }; + + expect(guard(event)).toBe(true); + await vi.advanceTimersByTimeAsync(25); + + expect(reissueQuit).toHaveBeenCalledOnce(); + expect(guard(event)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it("stores an immutable private patch with actual diff-check evidence and no all-zero hash", async () => { const workspace = root(); fs.writeFileSync( diff --git a/packages/desktop-app/src/main/multi-frontier-app-integration.ts b/packages/desktop-app/src/main/multi-frontier-app-integration.ts index dae78566fd..4e7df3f6ed 100644 --- a/packages/desktop-app/src/main/multi-frontier-app-integration.ts +++ b/packages/desktop-app/src/main/multi-frontier-app-integration.ts @@ -24,6 +24,7 @@ const execFileAsync = promisify(execFile); const MAX_EVIDENCE_BYTES = 16 * 1024; const MAX_GIT_OUTPUT_BYTES = 8 * 1024; const MAX_SNAPSHOT_PATCH_BYTES = 256 * 1024; +const DEFAULT_QUIT_DISPOSE_TIMEOUT_MS = 5_000; const SAFE_WORKSPACE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/; const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null"; @@ -149,6 +150,7 @@ export function initializeMultiFrontierAppIntegration( export function createMultiFrontierQuitGuard(options: { dispose(): Promise; reissueQuit(): void; + disposeTimeoutMs?: number; }): (event: MultiFrontierQuitEvent) => boolean { let reissued = false; let disposing: Promise | undefined; @@ -156,13 +158,21 @@ export function createMultiFrontierQuitGuard(options: { if (reissued) return false; event.preventDefault(); if (!disposing) { - disposing = options - .dispose() - .catch(() => undefined) - .finally(() => { - reissued = true; - options.reissueQuit(); - }); + const disposal = Promise.resolve() + .then(() => options.dispose()) + .catch(() => undefined); + let timeoutHandle: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timeoutHandle = setTimeout( + resolve, + options.disposeTimeoutMs ?? DEFAULT_QUIT_DISPOSE_TIMEOUT_MS, + ); + }); + disposing = Promise.race([disposal, timeout]).finally(() => { + if (timeoutHandle) clearTimeout(timeoutHandle); + reissued = true; + options.reissueQuit(); + }); } return true; }; diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 684c34340a..8509c4388f 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -190,6 +190,9 @@ acceptance-state: - Electron 41.9 runtime proof observed an exact completion navigation commit with HTTP 200 and one obvious-fake target cookie becoming available afterward; no task Electron or updater process remained - completion regressions prove will-redirect is not cancelled, no cookie read begins before did-navigate with HTTP 200, 401 completion fails closed without reading or copying cookies, a wrong nonce is ignored, direct Dispatch completion uses the same committed boundary, and diagnostics contain app id plus status only - independent review found and the implementation repaired a cancellation race: every new ceremony now drains tracked prior session-copy cleanup before opening, and immediate reauthentication cannot lose its new target cookie to a late cleanup; re-review found no remaining actionable source defect + - exact-head CI run 30755672926 and signed canary run 30755671927 passed on 6426bcad3d98bda7e13e774137d257886f675cab; canary .23 matched all four manifest hashes and passed strict signing, notarization, Gatekeeper, bundle identity, version, signer, and arm64 checks + - independent native canary .23 H1 passed Settings over a slow-loading Mail webview and H2 passed more than four minutes of updater isolation; stable profile mtime remained unchanged and no credential or production mutation occurred + - canary .23 H3 failed because normal Cmd+Q removed the window while the main and helper processes remained alive; root terminated only the exact Canary process tree and verified process absence, identifying the pre-existing Multi-Frontier quit guard as a signed-candidate blocker rather than entering production implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -203,8 +206,9 @@ acceptance-state: - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption - the ordinary sign-in entry in a canonical first-party app starts the app-targeted workspace ceremony; Settings exposes status and workspace sign-out but no separate authority sign-in command - exact nonce-bound app-local completion must commit with HTTP 200 before isolated app-session transfer, followed only by bounded allowlisted-cookie synchronization and value-free diagnostics + - Desktop quit waits for Multi-Frontier cleanup but reissues application quit after synchronous failure or a five-second bound so optional collaboration disposal cannot leave a windowless process indefinitely blockers: - - the committed-completion repair must pass exact-head CI and a newly signed isolated canary before another production window; local runtime and test proof are complete + - the bounded quit-lifecycle repair must pass exact-head CI, a newly signed isolated canary, and a repeated normal quit/process-absence/relaunch check before another production window; the committed-completion code and canary .23 H1-H2 gates are otherwise complete - a renewed short Mail and Dispatch window must prove H1-H10, including same-account continuity, restart, provider separation, workspace sign-out, and safe cancellation/concurrency; the prior H1-H2 evidence is informative but cannot complete acceptance for the repaired artifact - Alice requires an explicit notification and agent stop before any next production test or Netlify production mutation latest-production-result: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5158731013 @@ -242,7 +246,7 @@ production-safety-preflight: work-constraints: production-test-custody: notify Alice and stop before any production canary or Netlify production mutation pr-cleanup: remove agent-authored progress-comment clutter after preserving durable truth locally; retain human review and concise reviewer-facing information -ledger-revision: desktop-sso-work-r25 +ledger-revision: desktop-sso-work-r26 status: active ``` From 579d5f590cb06fffd807669d2b4ea343b49b13ec Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:54:47 -0400 Subject: [PATCH 27/31] Keep shell modals above Desktop webviews --- .../desktop-app/src/renderer/App.spec.tsx | 35 +++++++++++++++---- packages/desktop-app/src/renderer/App.tsx | 8 +++-- plans/desktop-sso-intent/work-ledger.md | 7 ++-- 3 files changed, 38 insertions(+), 12 deletions(-) diff --git a/packages/desktop-app/src/renderer/App.spec.tsx b/packages/desktop-app/src/renderer/App.spec.tsx index 01fae4f859..8999c319cc 100644 --- a/packages/desktop-app/src/renderer/App.spec.tsx +++ b/packages/desktop-app/src/renderer/App.spec.tsx @@ -11,7 +11,14 @@ vi.mock("sonner", () => ({ })); vi.mock("./components/AppSettings.js", () => ({ - default: () =>
App Settings
, + default: ({ onClose }: { onClose: () => void }) => ( +
+ App Settings + +
+ ), AddAppDialog: () => null, AppEditForm: () => null, })); @@ -19,8 +26,10 @@ vi.mock("./components/AppSettings.js", () => ({ vi.mock("./components/AppWebview.js", async () => { const react = await import("react"); return { - default: react.forwardRef(() => ( -
Still loading
+ default: react.forwardRef(({ isActive }: { isActive: boolean }) => ( +
+ Still loading +
)), }; }); @@ -75,9 +84,8 @@ describe("Desktop shell Settings boundary", () => { await Promise.resolve(); }); - expect( - container.querySelector('[data-testid="slow-webview"]'), - ).not.toBeNull(); + const slowWebview = container.querySelector('[data-testid="slow-webview"]'); + expect(slowWebview?.getAttribute("data-active")).toBe("true"); const settings = container.querySelector( 'button[aria-label="Settings"]', ); @@ -86,7 +94,20 @@ describe("Desktop shell Settings boundary", () => { act(() => settings?.click()); expect(container.querySelector('[role="dialog"]')?.textContent).toBe( - "App Settings", + "App SettingsClose Settings", ); + expect(slowWebview?.getAttribute("data-active")).toBe("false"); + + const closeSettings = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Close Settings", + ); + expect(closeSettings).not.toBeNull(); + act(() => closeSettings?.click()); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(container.querySelector('[data-testid="slow-webview"]')).toBe( + slowWebview, + ); + expect(slowWebview?.getAttribute("data-active")).toBe("true"); }); }); diff --git a/packages/desktop-app/src/renderer/App.tsx b/packages/desktop-app/src/renderer/App.tsx index 5cb6308511..1d26a0302a 100644 --- a/packages/desktop-app/src/renderer/App.tsx +++ b/packages/desktop-app/src/renderer/App.tsx @@ -839,6 +839,8 @@ export default function App() { showCodeAgentsTab && activeSidebarAppId === CODE_AGENTS_SURFACE_ID; const shouldRenderCodeAgents = showCodeAgentsTab && (isCodeAgentsActive || hasMountedCodeAgents); + const shellModalOpen = + showSettings || showAddApp || editingSidebarAppId !== null; // Keep app webviews warm once visited so switching apps feels like browser // tabs: the guest page remains alive offscreen and keeps its runtime state. @@ -973,7 +975,7 @@ export default function App() { > setShowSettings(true)} @@ -990,11 +992,11 @@ export default function App() { }} app={appDef} appConfig={app} - isActive={isActive} + isActive={isActive && !shellModalOpen} urlOpenNonce={tab.urlOpenNonce} urlPath={tab.urlPath} urlOpenSoft={tab.urlOpenSoft} - refreshKey={isActive ? refreshKey : 0} + refreshKey={isActive && !shellModalOpen ? refreshKey : 0} onTitleChange={(title) => handleTabTitleChange(tab.id, title)} onAppsChanged={handleAppsChanged} /> diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 8509c4388f..9a9aa358e9 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -193,6 +193,8 @@ acceptance-state: - exact-head CI run 30755672926 and signed canary run 30755671927 passed on 6426bcad3d98bda7e13e774137d257886f675cab; canary .23 matched all four manifest hashes and passed strict signing, notarization, Gatekeeper, bundle identity, version, signer, and arm64 checks - independent native canary .23 H1 passed Settings over a slow-loading Mail webview and H2 passed more than four minutes of updater isolation; stable profile mtime remained unchanged and no credential or production mutation occurred - canary .23 H3 failed because normal Cmd+Q removed the window while the main and helper processes remained alive; root terminated only the exact Canary process tree and verified process absence, identifying the pre-existing Multi-Frontier quit guard as a signed-candidate blocker rather than entering production + - exact-head CI run 30756794521 and signed canary run 30756792323 passed on d6607b41c64451e82cf1ab6f9214759af9bfe86c; canary .24 passed manifest, signing, notarization, Gatekeeper, isolated-profile, updater-silence, normal-quit, process-absence, and relaunch checks while stable profile mtime remained unchanged + - independent canary .24 H1 proved Settings functionally opens over slow-loading Mail, but its native guest backdrop became blank gray and the modal content later blanked during H2; H3 relaunch restored normal rendering, localizing the remaining gate to macOS native-guest compositing rather than React state or quit lifecycle implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -207,8 +209,9 @@ acceptance-state: - the ordinary sign-in entry in a canonical first-party app starts the app-targeted workspace ceremony; Settings exposes status and workspace sign-out but no separate authority sign-in command - exact nonce-bound app-local completion must commit with HTTP 200 before isolated app-session transfer, followed only by bounded allowlisted-cookie synchronization and value-free diagnostics - Desktop quit waits for Multi-Frontier cleanup but reissues application quit after synchronous failure or a five-second bound so optional collaboration disposal cannot leave a windowless process indefinitely + - every App-owned shell modal makes ordinary and Code Agents guest webviews inactive through the existing offscreen/background path, then restores the same mounted guest and deferred refresh behavior without navigation or reload blockers: - - the bounded quit-lifecycle repair must pass exact-head CI, a newly signed isolated canary, and a repeated normal quit/process-absence/relaunch check before another production window; the committed-completion code and canary .23 H1-H2 gates are otherwise complete + - the native guest-compositing repair must pass exact-head CI, a newly signed isolated canary, and a Settings-over-slow-loading-webview observation held beyond four minutes before another production window; committed completion, updater isolation, and normal quit/relaunch gates are otherwise complete - a renewed short Mail and Dispatch window must prove H1-H10, including same-account continuity, restart, provider separation, workspace sign-out, and safe cancellation/concurrency; the prior H1-H2 evidence is informative but cannot complete acceptance for the repaired artifact - Alice requires an explicit notification and agent stop before any next production test or Netlify production mutation latest-production-result: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5158731013 @@ -246,7 +249,7 @@ production-safety-preflight: work-constraints: production-test-custody: notify Alice and stop before any production canary or Netlify production mutation pr-cleanup: remove agent-authored progress-comment clutter after preserving durable truth locally; retain human review and concise reviewer-facing information -ledger-revision: desktop-sso-work-r26 +ledger-revision: desktop-sso-work-r27 status: active ``` From cff2f0a0454c536ada52a045791ec6e62d6ce047 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 13:39:20 -0400 Subject: [PATCH 28/31] Remove modal webviews from native layout --- packages/desktop-app/src/renderer/App.spec.tsx | 10 ++++++++++ packages/desktop-app/src/renderer/App.tsx | 2 +- packages/desktop-app/src/renderer/shell.css | 6 ++++++ plans/desktop-sso-intent/work-ledger.md | 9 ++++++--- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/desktop-app/src/renderer/App.spec.tsx b/packages/desktop-app/src/renderer/App.spec.tsx index 8999c319cc..255893dc2f 100644 --- a/packages/desktop-app/src/renderer/App.spec.tsx +++ b/packages/desktop-app/src/renderer/App.spec.tsx @@ -86,6 +86,10 @@ describe("Desktop shell Settings boundary", () => { const slowWebview = container.querySelector('[data-testid="slow-webview"]'); expect(slowWebview?.getAttribute("data-active")).toBe("true"); + const contentArea = container.querySelector(".content-area"); + expect( + contentArea?.classList.contains("content-area--shell-modal-open"), + ).toBe(false); const settings = container.querySelector( 'button[aria-label="Settings"]', ); @@ -97,6 +101,9 @@ describe("Desktop shell Settings boundary", () => { "App SettingsClose Settings", ); expect(slowWebview?.getAttribute("data-active")).toBe("false"); + expect( + contentArea?.classList.contains("content-area--shell-modal-open"), + ).toBe(true); const closeSettings = Array.from(container.querySelectorAll("button")).find( (button) => button.textContent === "Close Settings", @@ -109,5 +116,8 @@ describe("Desktop shell Settings boundary", () => { slowWebview, ); expect(slowWebview?.getAttribute("data-active")).toBe("true"); + expect( + contentArea?.classList.contains("content-area--shell-modal-open"), + ).toBe(false); }); }); diff --git a/packages/desktop-app/src/renderer/App.tsx b/packages/desktop-app/src/renderer/App.tsx index 1d26a0302a..7db81120ef 100644 --- a/packages/desktop-app/src/renderer/App.tsx +++ b/packages/desktop-app/src/renderer/App.tsx @@ -964,7 +964,7 @@ export default function App() {
{shouldRenderCodeAgents && (
Date: Sun, 2 Aug 2026 14:55:28 -0400 Subject: [PATCH 29/31] Enable Google sign-in on hosted Dispatch --- plans/desktop-sso-intent/work-ledger.md | 23 +++++++--- .../2026-08-02-google-workspace-sign-in.md | 5 +++ .../dispatch/server/lib/hosted-auth.spec.ts | 45 +++++++++++++++++++ templates/dispatch/server/lib/hosted-auth.ts | 21 +++++++++ .../dispatch/server/plugins/setup-dispatch.ts | 3 ++ 5 files changed, 90 insertions(+), 7 deletions(-) create mode 100644 templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md create mode 100644 templates/dispatch/server/lib/hosted-auth.spec.ts create mode 100644 templates/dispatch/server/lib/hosted-auth.ts diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 432638d27f..2d6d6795a4 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -27,7 +27,7 @@ write-targets: - plans/desktop-sso-intent/work-ledger.md governing-artifact: path: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary diagnosis and replacement work plan 2026-08-02.md - revision: desktop-sso-diagnosis-plan-r1 + revision: desktop-sso-diagnosis-plan-r3 architecture-fingerprint: outcome: one Agent Native workspace sign-in begun from the ordinary front door of any canonical first-party app, followed by silent app-local federation shipping-surfaces: @@ -198,6 +198,15 @@ acceptance-state: - exact-head CI run 30757701197 and signed canary run 30757699380 passed on 579d5f590cb06fffd807669d2b4ea343b49b13ec; canary .25 passed manifest, signing, notarization, Gatekeeper, bundle identity, version, signer, architecture, isolated-profile, and updater-silence checks while stable profile mtime remained unchanged - a reliable local CDP plus CGWindow harness replaced the prior accessibility lease that closed the Electron browser when detaching; canary .25 then passed initial native H1 with Settings readable and both guest slots offscreen, but its unattended four-minute CGWindow capture was blank while React still reported Settings visible, so H2 was not accepted and production remained untouched - local compositor hardening removes every guest slot from layout with display:none only while an App-owned shell modal is open, preserves the same two WebContents target ids across close, and restores the active guest without navigation or remount; the four-minute local hold remained readable when the app was activated and independent review found no actionable lifecycle, Code Agents, fullscreen, refresh, or state-preservation defect + - exact-head CI run 30759362478 and Desktop SSO Canary run 30759360971 passed on cff2f0a0454c536ada52a045791ec6e62d6ce047; Canary .26 passed signing, notarization, isolated-profile, Settings compositor, updater-silence, normal-quit, process-absence, and relaunch gates + - the supervised .26 production attempt reached the correct canonical Dispatch ceremony from ordinary Mail, then stopped before authentication because the promoted Deploy Preview candidate rendered only email/password while Alice's workspace account is Google-only + - Deploy Preview candidate 6a6f8bdd884a450856725dfc is exact head cff2f0a0454c536ada52a045791ec6e62d6ce047 and its immutable sign-in document contains password forms but no google-btn element; its Netlify context is deploy-preview + - the earlier Dispatch candidate 6a6dfc950e84fea87e565474 that supported Alice's private Google ceremony was built in Netlify production context on the same branch, establishing deployment context as the smallest current diagnosis rather than a Desktop or cross-app protocol defect + - exact-head unpublished branch candidates 6a6f90d3d8056b4161659f28 (Dispatch) and 6a6f90fba1156f7c5ab0fa43 (Mail) were rebuilt without cache in branch-deploy context; both are ready at cff2f0a0454c536ada52a045791ec6e62d6ce047 and neither is published + - the fresh production-shaped Dispatch candidate still renders three password inputs and no google-btn, disproving build context alone as the repair; valid Mail callback authorize remains 302 and a hostile callback remains 400 + - canonical-host-only Dispatch repair passes 4 focused resolver tests, all 45 Dispatch template tests, template typecheck, formatting, diff hygiene, and credential-pattern scan + - independent read-only auth review found no actionable issue in exact-origin matching, environment precedence, local/self-hosted preservation, startup evaluation, or test coverage + - the failed window rolled back Mail then Dispatch to normal main@9258da4 deploys 6a6f49a689f67900088bb2ea and 6a6f49a6474bd80008d64fff; both sites are unlocked with auto-publishing on, identity routes baseline 401, Mail root normal 302, no Canary/updater process, and stable profile mtime unchanged implementation: - authenticated nonce-only app-local completion route in Core - dedicated persistent Dispatch identity partition in packaged Desktop @@ -214,7 +223,7 @@ acceptance-state: - Desktop quit waits for Multi-Frontier cleanup but reissues application quit after synchronous failure or a five-second bound so optional collaboration disposal cannot leave a windowless process indefinitely - every App-owned shell modal makes ordinary and Code Agents guest webviews inactive and removes their native slots from layout, then restores the same mounted guests and deferred refresh behavior without navigation or reload blockers: - - the strengthened native guest-compositing repair must pass exact-head CI, a newly signed isolated canary, and an activated Settings-over-slow-loading-webview observation held beyond four minutes before another production window; committed completion, updater isolation, and normal quit/relaunch gates are otherwise complete + - commit and push the canonical-host-only Dispatch repair, then rebuild unpublished exact-head candidates and prove the immutable Dispatch sign-in document contains google-btn before any production publication - a renewed short Mail and Dispatch window must prove H1-H10, including same-account continuity, restart, provider separation, workspace sign-out, and safe cancellation/concurrency; the prior H1-H2 evidence is informative but cannot complete acceptance for the repaired artifact - Alice requires an explicit notification and agent stop before any next production test or Netlify production mutation latest-production-result: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5158731013 @@ -245,14 +254,14 @@ return-to-shape: risk-strategy: unchanged system-ready, production validation before merge replacement-acceptance-story: desktop-first-party-sso-committed-completion-v3 approved production-safety-preflight: - verified-at: 2026-08-01T11:24:00-04:00 - mail: normal unlocked auto-publishing main deploy 6a6ddd1e966031000859bd7e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity route baseline 401, root normal 302 - dispatch: normal unlocked auto-publishing main deploy 6a6ddd1e8eef7d00084f719e at 3e89c5e5aea6752beea0849fbd62c5a1d58b986d, identity authorize baseline 401 - local: no SSO Canary, ShipIt, or Squirrel process remains; stable Desktop untouched + verified-at: 2026-08-02T14:46:39-04:00 + mail: normal unlocked auto-publishing main deploy 6a6f49a689f67900088bb2ea at 9258da46f4b0291860913b1ec05743b6330a92e2, identity route baseline 401, root normal 302 + dispatch: normal unlocked auto-publishing main deploy 6a6f49a6474bd80008d64fff at 9258da46f4b0291860913b1ec05743b6330a92e2, identity authorize baseline 401 + local: no SSO Canary, ShipIt, Squirrel, or updater process remains; stable Desktop profile mtime unchanged at 1785439008 work-constraints: production-test-custody: notify Alice and stop before any production canary or Netlify production mutation pr-cleanup: remove agent-authored progress-comment clutter after preserving durable truth locally; retain human review and concise reviewer-facing information -ledger-revision: desktop-sso-work-r28 +ledger-revision: desktop-sso-work-r31 status: active ``` diff --git a/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md b/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md new file mode 100644 index 0000000000..ac4ad1e070 --- /dev/null +++ b/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md @@ -0,0 +1,5 @@ +--- +type: fixed +date: 2026-08-02 +--- +Hosted Dispatch now offers the workspace Google sign-in used by Agent Native Desktop. diff --git a/templates/dispatch/server/lib/hosted-auth.spec.ts b/templates/dispatch/server/lib/hosted-auth.spec.ts new file mode 100644 index 0000000000..1c5b713721 --- /dev/null +++ b/templates/dispatch/server/lib/hosted-auth.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; + +import { isFirstPartyHostedDispatch } from "./hosted-auth"; + +describe("isFirstPartyHostedDispatch", () => { + it("enables Google-only auth for the canonical hosted Dispatch origin", () => { + expect( + isFirstPartyHostedDispatch({ + URL: "https://dispatch.agent-native.com", + }), + ).toBe(true); + }); + + it("keeps self-hosted and local Dispatch auth unchanged", () => { + expect( + isFirstPartyHostedDispatch({ + URL: "https://dispatch.example.com", + }), + ).toBe(false); + expect( + isFirstPartyHostedDispatch({ + APP_URL: "http://localhost:8080", + }), + ).toBe(false); + expect(isFirstPartyHostedDispatch({})).toBe(false); + }); + + it("honors an explicit public origin before the platform site URL", () => { + expect( + isFirstPartyHostedDispatch({ + APP_URL: "https://dispatch.example.com", + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + }); + + it("rejects malformed and lookalike origins", () => { + expect(isFirstPartyHostedDispatch({ URL: "not a url" })).toBe(false); + expect( + isFirstPartyHostedDispatch({ + URL: "https://dispatch.agent-native.com.evil.example", + }), + ).toBe(false); + }); +}); diff --git a/templates/dispatch/server/lib/hosted-auth.ts b/templates/dispatch/server/lib/hosted-auth.ts new file mode 100644 index 0000000000..ecc1327723 --- /dev/null +++ b/templates/dispatch/server/lib/hosted-auth.ts @@ -0,0 +1,21 @@ +const FIRST_PARTY_DISPATCH_ORIGIN = "https://dispatch.agent-native.com"; + +type PublicOriginEnv = Partial< + Pick +>; + +export function isFirstPartyHostedDispatch( + env: PublicOriginEnv = process.env, +): boolean { + const configuredOrigin = [env.APP_URL, env.BETTER_AUTH_URL, env.URL].find( + (value) => value?.trim(), + ); + + if (!configuredOrigin) return false; + + try { + return new URL(configuredOrigin).origin === FIRST_PARTY_DISPATCH_ORIGIN; + } catch { + return false; + } +} diff --git a/templates/dispatch/server/plugins/setup-dispatch.ts b/templates/dispatch/server/plugins/setup-dispatch.ts index 17abb3c102..fd831cc772 100644 --- a/templates/dispatch/server/plugins/setup-dispatch.ts +++ b/templates/dispatch/server/plugins/setup-dispatch.ts @@ -1,7 +1,10 @@ import { setupDispatch } from "@agent-native/dispatch/server"; +import { isFirstPartyHostedDispatch } from "../lib/hosted-auth"; + export default setupDispatch({ auth: { + googleOnly: isFirstPartyHostedDispatch(), publicPaths: [ "/_agent-native/identity/authorize", "/_agent-native/org/apps", From 2506adc6771af12ada0cd1da6fd20271cd119ce2 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:01:27 -0400 Subject: [PATCH 30/31] Format Dispatch changelog entry --- .../dispatch/changelog/2026-08-02-google-workspace-sign-in.md | 1 + 1 file changed, 1 insertion(+) diff --git a/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md b/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md index ac4ad1e070..074c4678a3 100644 --- a/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md +++ b/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md @@ -2,4 +2,5 @@ type: fixed date: 2026-08-02 --- + Hosted Dispatch now offers the workspace Google sign-in used by Agent Native Desktop. From f1820d70ebcaa57c887c3886c102f6d3aed68c6b Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:36:23 -0400 Subject: [PATCH 31/31] Gate hosted Dispatch Google auth by deploy context --- plans/desktop-sso-intent/work-ledger.md | 38 +++++++++++++++++++ .../2026-08-02-google-workspace-sign-in.md | 2 +- .../dispatch/server/lib/hosted-auth.spec.ts | 38 ++++++++++++++++++- templates/dispatch/server/lib/hosted-auth.ts | 4 +- 4 files changed, 78 insertions(+), 4 deletions(-) diff --git a/plans/desktop-sso-intent/work-ledger.md b/plans/desktop-sso-intent/work-ledger.md index 2d6d6795a4..94906071cb 100644 --- a/plans/desktop-sso-intent/work-ledger.md +++ b/plans/desktop-sso-intent/work-ledger.md @@ -291,3 +291,41 @@ Approve option 2. It is the smallest delta that addresses both the concrete Clip ### Replacement successful-user story A user opens any canonical first-party app in packaged Desktop and uses that app's normal sign-in. Agent Native performs one workspace identity ceremony, returns to the initiating app without exposing Dispatch home, and silently signs subsequent canonical apps into their correct existing local accounts. Settings accurately reports the shared account and supports workspace sign-out or recovery. Standalone browsers, custom apps, local development, Builder credentials, app-local authorization, and provider-specific connection consent remain unchanged. + +## Work r34 — production-context route readiness + +- Governing artifact: `/Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary diagnosis and replacement work plan 2026-08-02.md` +- Governing revision: `desktop-sso-diagnosis-plan-r4` +- Lifecycle state: Work active +- Authority source: Alice invoked `$work` on 2026-08-03 and required an explicit stop before the next production test. +- Starting PR head: `2506adc6771af12ada0cd1da6fd20271cd119ce2` +- Refreshed base: `origin/main@6a771571028d8e2a911eb0d1bcda607c32c555ca` +- Dedicated lane: `/Users/alicemoore/.codex/worktrees/desktop-workspace-sso/agent-native`, branch `codex/desktop-workspace-sso`, clean at start and equal to the open PR head. + +### Bounded repair + +- Hosted Dispatch now requests `googleOnly` only when the first configured public origin is exactly `https://dispatch.agent-native.com` and Netlify's deploy context is exactly `production`. +- Deploy Preview, branch-deploy, missing-context, local, self-hosted, malformed, and lookalike cases fail closed to the compatible password-capable presentation. +- Explicit `APP_URL` and `BETTER_AUTH_URL` continue to take precedence over the platform `URL`. +- The existing pending Dispatch changelog entry now states the production behavior and preview fallback without adding a duplicate entry. +- No Core route, OAuth credential, credential scope, callback, Desktop release workflow, stable updater, production deploy, or merge state changed in this slice. + +### Current local evidence + +- Focused hosted-auth resolver: 5 tests passed. +- Full Dispatch template suite: 46 tests passed across 4 files. +- Dispatch template typecheck: passed. +- `guard:no-secret-literals`: passed. +- `guard:no-env-credentials`: passed. +- Formatting and `git diff --check`: passed. +- Independent auth/deploy-context review: no actionable finding; exact context/origin fail-closed behavior and explicit-origin precedence were confirmed. A follow-up assertion was added for `BETTER_AUTH_URL` precedence. + +### Outstanding acceptance + +- Commit and push the bounded repair, then require fresh exact-head normal CI and signed Desktop Canary checks. +- Build unpublished Dispatch and Mail drafts with Netlify production context without `--prod`; prove C7-C9, including a real `/_agent-native/google/auth-url` 302 from the immutable Dispatch candidate. +- Verify the exact signed artifact and isolated Canary app/profile through the non-production C6 gates. +- Refresh current production rollback and route baselines, then notify Alice and stop before any lock or publication. +- H0-H10 remain unstarted for the repaired exact artifact. PR #2290 remains open and unmerged. + +Ledger revision: `desktop-sso-work-r34`. diff --git a/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md b/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md index 074c4678a3..a092aefd46 100644 --- a/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md +++ b/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md @@ -3,4 +3,4 @@ type: fixed date: 2026-08-02 --- -Hosted Dispatch now offers the workspace Google sign-in used by Agent Native Desktop. +Hosted Dispatch now offers Desktop workspace Google sign-in in production without showing an unusable Google-only entry in previews. diff --git a/templates/dispatch/server/lib/hosted-auth.spec.ts b/templates/dispatch/server/lib/hosted-auth.spec.ts index 1c5b713721..2a94f4ebff 100644 --- a/templates/dispatch/server/lib/hosted-auth.spec.ts +++ b/templates/dispatch/server/lib/hosted-auth.spec.ts @@ -3,22 +3,45 @@ import { describe, expect, it } from "vitest"; import { isFirstPartyHostedDispatch } from "./hosted-auth"; describe("isFirstPartyHostedDispatch", () => { - it("enables Google-only auth for the canonical hosted Dispatch origin", () => { + it("enables Google-only auth for canonical production Dispatch", () => { expect( isFirstPartyHostedDispatch({ + CONTEXT: "production", URL: "https://dispatch.agent-native.com", }), ).toBe(true); }); + it("keeps preview and branch deploys password-capable", () => { + expect( + isFirstPartyHostedDispatch({ + CONTEXT: "deploy-preview", + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + expect( + isFirstPartyHostedDispatch({ + CONTEXT: "branch-deploy", + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + expect( + isFirstPartyHostedDispatch({ + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + }); + it("keeps self-hosted and local Dispatch auth unchanged", () => { expect( isFirstPartyHostedDispatch({ + CONTEXT: "production", URL: "https://dispatch.example.com", }), ).toBe(false); expect( isFirstPartyHostedDispatch({ + CONTEXT: "production", APP_URL: "http://localhost:8080", }), ).toBe(false); @@ -29,15 +52,26 @@ describe("isFirstPartyHostedDispatch", () => { expect( isFirstPartyHostedDispatch({ APP_URL: "https://dispatch.example.com", + CONTEXT: "production", + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + expect( + isFirstPartyHostedDispatch({ + BETTER_AUTH_URL: "https://dispatch.example.com", + CONTEXT: "production", URL: "https://dispatch.agent-native.com", }), ).toBe(false); }); it("rejects malformed and lookalike origins", () => { - expect(isFirstPartyHostedDispatch({ URL: "not a url" })).toBe(false); + expect( + isFirstPartyHostedDispatch({ CONTEXT: "production", URL: "not a url" }), + ).toBe(false); expect( isFirstPartyHostedDispatch({ + CONTEXT: "production", URL: "https://dispatch.agent-native.com.evil.example", }), ).toBe(false); diff --git a/templates/dispatch/server/lib/hosted-auth.ts b/templates/dispatch/server/lib/hosted-auth.ts index ecc1327723..f6875234c8 100644 --- a/templates/dispatch/server/lib/hosted-auth.ts +++ b/templates/dispatch/server/lib/hosted-auth.ts @@ -1,12 +1,14 @@ const FIRST_PARTY_DISPATCH_ORIGIN = "https://dispatch.agent-native.com"; type PublicOriginEnv = Partial< - Pick + Pick >; export function isFirstPartyHostedDispatch( env: PublicOriginEnv = process.env, ): boolean { + if (env.CONTEXT?.trim() !== "production") return false; + const configuredOrigin = [env.APP_URL, env.BETTER_AUTH_URL, env.URL].find( (value) => value?.trim(), );