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 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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();