diff --git a/.agents/skills/authentication/SKILL.md b/.agents/skills/authentication/SKILL.md index 6aea717825..f87bbd3774 100644 --- a/.agents/skills/authentication/SKILL.md +++ b/.agents/skills/authentication/SKILL.md @@ -166,6 +166,30 @@ 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. + +The ordinary sign-in entry in any canonical first-party Desktop app is the +workspace sign-in front door. Desktop runs that app's existing federation +ceremony in the dedicated partition, then returns only the app-local session to +the initiating app. Desktop Settings reports workspace status and signs out; it +must not expose Dispatch home as a sign-in completion surface. + +Custom, user-added, and third-party apps are excluded. Preserve direct web +sign-in and the per-app `AGENT_NATIVE_IDENTITY_HUB_URL` opt-in/canary behavior. +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..43086d03c8 --- /dev/null +++ b/.changeset/calm-desktops-federate.md @@ -0,0 +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..58d3f7d613 --- /dev/null +++ b/.github/workflows/desktop-canary.yml @@ -0,0 +1,114 @@ +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: | + 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 + 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/core/docs/content/authentication.mdx b/packages/core/docs/content/authentication.mdx index a3a2f78511..570f896b9b 100644 --- a/packages/core/docs/content/authentication.mdx +++ b/packages/core/docs/content/authentication.mdx @@ -140,6 +140,29 @@ These deploys must provide `APP_NAME` or a derivable app URL (`APP_URL`, 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 1041ef9eee..a5b3c5b9d4 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 983ba9db80..f0f1f4becf 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 bc13bf328b..5978ce3e91 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 ce341c8220..2511edf973 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 7e8fcfa6b5..290bf0a0d2 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 4b3f3e8380..f51a4d3717 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 2d310df0fd..d57cceca28 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 88af0f27f4..ead726299f 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 af425cd118..904c2b8d0a 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 16d374c6cc..5f1bfb318c 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 f4c90af4e4..5a004e4ed1 100644 --- a/packages/core/src/server/core-routes-plugin.ts +++ b/packages/core/src/server/core-routes-plugin.ts @@ -4173,6 +4173,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 de203ca44a..8aa395cdaa 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/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md b/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md index 6aea717825..f87bbd3774 100644 --- a/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md +++ b/packages/core/src/templates/workspace-core/.agents/skills/authentication/SKILL.md @@ -166,6 +166,30 @@ 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. + +The ordinary sign-in entry in any canonical first-party Desktop app is the +workspace sign-in front door. Desktop runs that app's existing federation +ceremony in the dedicated partition, then returns only the app-local session to +the initiating app. Desktop Settings reports workspace status and signs out; it +must not expose Dispatch home as a sign-in completion surface. + +Custom, user-added, and third-party apps are excluded. Preserve direct web +sign-in and the per-app `AGENT_NATIVE_IDENTITY_HUB_URL` opt-in/canary behavior. +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/packages/desktop-app/shared/ipc-channels.ts b/packages/desktop-app/shared/ipc-channels.ts index 9b757a4db5..4b41467628 100644 --- a/packages/desktop-app/shared/ipc-channels.ts +++ b/packages/desktop-app/shared/ipc-channels.ts @@ -26,6 +26,11 @@ 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_OUT: "identity:sign-out", + /** App config management (renderer ↔ main) */ APPS_LOAD: "apps:load", APPS_ADD: "apps:add", @@ -257,6 +262,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..f456fc6cf6 --- /dev/null +++ b/packages/desktop-app/src/main/desktop-identity.spec.ts @@ -0,0 +1,1380 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + DESKTOP_IDENTITY_COMPLETE_PATH, + DesktopIdentityBroker, + desktopWorkspaceLogoutPath, + isDesktopIdentityAuthorizeNavigation, + isDesktopIdentityCompletion, + isDesktopIdentityConfiguredAppEligible, + 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(), + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } as unknown as Electron.Session, + }; +} + +function authorityFixture(): DesktopIdentityApp { + return { + ...appFixture(), + id: "dispatch", + origin: "https://dispatch.agent-native.com", + cookieNames: ["an_session_dispatch", "an_session"], + cookieNamesToClear: [ + "an_session_dispatch", + "an_session", + "an_dispatch.session_token", + "__Secure-an_dispatch.session_token", + ], + identityAuthority: true, + }; +} + +function authorizeUrl( + authority: DesktopIdentityApp, + target: DesktopIdentityApp, +): string { + const result = new URL("/_agent-native/identity/authorize", authority.origin); + result.searchParams.set("app", target.id); + result.searchParams.set( + "redirect_uri", + new URL("/_agent-native/identity/callback", target.origin).toString(), + ); + result.searchParams.set("state", "state_123"); + return result.toString(); +} + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + 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(); + + 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("accepts authorize redirects only for the exact authority and callback", () => { + const app = appFixture(); + const authority = authorityFixture(); + expect( + isDesktopIdentityAuthorizeNavigation( + authorizeUrl(authority, app), + authority, + app, + ), + ).toBe(true); + expect( + isDesktopIdentityAuthorizeNavigation( + authorizeUrl(authority, { ...app, id: "calendar" }), + authority, + app, + ), + ).toBe(false); + const hostile = new URL(authorizeUrl(authority, app)); + hostile.searchParams.set( + "redirect_uri", + "https://evil.example/_agent-native/identity/callback", + ); + expect( + isDesktopIdentityAuthorizeNavigation(hostile.toString(), authority, app), + ).toBe(false); + }); + + it("recognizes workspace logout only on the canonical app origin", () => { + expect( + isDesktopWorkspaceLogoutRequest( + "https://mail.agent-native.com/_agent-native/auth/logout", + app, + ), + ).toBe(true); + expect( + isDesktopWorkspaceLogoutRequest( + "https://evil.example/_agent-native/auth/logout", + 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); + }); +}); + +describe("DesktopIdentityBroker", () => { + it("does not replace an active app-led ceremony with a stale cookie status", async () => { + const app = appFixture(); + const authority = authorityFixture(); + const statusCookies = deferred(); + const preflight = deferred(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: { + ...cookieStore(), + get: vi.fn(() => statusCookies.promise), + }, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect: vi.fn(() => preflight.promise), + resolveApp: (id) => + id === app.id ? app : id === authority.id ? authority : null, + createWindow: vi.fn() as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const refresh = broker.refreshStatus(authority); + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(broker.getStatus()).toBe("signing-in")); + statusCookies.resolve([ + sessionCookie("an_session_dispatch", authority.origin), + ]); + await refresh; + expect(broker.getStatus()).toBe("signing-in"); + + preflight.resolve(null); + await expect(ceremony).resolves.toBe(false); + }); + + it("does not inspect or replace status during workspace sign-out", async () => { + const authority = authorityFixture(); + const identityCookies = cookieStore([ + sessionCookie("an_session_dispatch", authority.origin), + ]); + const centralCleanup = deferred(); + const clearStorageData = vi.fn(() => centralCleanup.promise); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData, + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } as unknown as Electron.Session, + resolveApp: (id) => (id === authority.id ? authority : null), + createWindow: vi.fn() as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const signOut = broker.signOut([authority]); + await vi.waitFor(() => expect(clearStorageData).toHaveBeenCalledOnce()); + const cookieReadsBeforeRefresh = identityCookies.get.mock.calls.length; + await broker.refreshStatus(authority); + + expect(identityCookies.get).toHaveBeenCalledTimes(cookieReadsBeforeRefresh); + centralCleanup.resolve(); + await signOut; + expect(broker.getStatus()).toBe("sign-in-required"); + }); + + it("loads the validated identity authority redirect", async () => { + const app = appFixture(); + const authority = authorityFixture(); + const resolvedUrl = authorizeUrl(authority, app); + const resolveLoginRedirect = vi.fn( + async (_loginUrl: string) => resolvedUrl, + ); + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents: { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }, + loadURL: vi.fn(async () => {}), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect, + resolveApp: (id) => + id === app.id ? app : id === authority.id ? authority : null, + createWindow: () => identityWindow as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => + expect(identityWindow.loadURL).toHaveBeenCalledWith(resolvedUrl), + ); + expect(resolveLoginRedirect).toHaveBeenCalledWith( + expect.stringContaining("/_agent-native/identity/login"), + expect.objectContaining({ cookies: expect.anything() }), + ); + closedListener?.(); + await expect(ceremony).resolves.toBe(false); + }); + + it("sanitizes identity preflight failures before logging", async () => { + const app = appFixture(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect: vi.fn(async () => { + throw new Error("redirect failed?state=secret&token=secret"); + }), + resolveApp: (id) => (id === app.id ? app : null), + createWindow: vi.fn() as never, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + await expect(broker.ensureAppSession(app.id)).resolves.toBe(false); + expect(warn).toHaveBeenCalledWith( + "[desktop-identity] identity preflight failed", + ); + expect(JSON.stringify(warn.mock.calls)).not.toContain("state=secret"); + expect(JSON.stringify(warn.mock.calls)).not.toContain("token=secret"); + expect(reloadApp).toHaveBeenCalledWith(app); + warn.mockRestore(); + }); + + it("rejects a redirect outside the identity authority", async () => { + const app = appFixture(); + const authority = authorityFixture(); + const createWindow = vi.fn(); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect: vi.fn( + async () => "https://evil.example/_agent-native/identity/authorize", + ), + resolveApp: (id) => + id === app.id ? app : id === authority.id ? authority : null, + createWindow, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + await expect(broker.ensureAppSession(app.id)).resolves.toBe(false); + expect(createWindow).not.toHaveBeenCalled(); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("failed"); + }); + + it("coalesces duplicate requests and copies only the target cookie", async () => { + const app = appFixture(); + const identityCookies = cookieStore([ + { + 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", + }, + ]); + identityCookies.get.mockImplementationOnce(async () => []); + 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 redirectHandler = webContents.on.mock.calls.find( + ([event]) => event === "will-redirect", + )?.[1]; + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + const preventDefault = vi.fn(); + redirectHandler({ preventDefault }, completion); + expect(identityCookies.get).not.toHaveBeenCalled(); + expect(preventDefault).not.toHaveBeenCalled(); + navigationHandler({}, completion, 200, "OK"); + + await expect(first).resolves.toBe(true); + expect(identityCookies.get).toHaveBeenCalledTimes(2); + expect(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("requires an authenticated committed completion before copying", async () => { + const app = appFixture(); + const identityCookies = cookieStore([ + sessionCookie("an_session_mail", app.origin), + ]); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const reloadApp = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + navigationHandler({}, completion, 401, "Unauthorized"); + + await expect(ceremony).resolves.toBe(false); + expect(identityCookies.get).not.toHaveBeenCalled(); + expect(app.session.cookies.set).not.toHaveBeenCalled(); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("failed"); + expect(warn).toHaveBeenCalledWith( + "[desktop-identity] authenticated completion failed", + { appId: "mail", statusCode: 401 }, + ); + expect(JSON.stringify(warn.mock.calls)).not.toContain("session-value"); + warn.mockRestore(); + }); + + it("ignores a committed completion with the wrong nonce", async () => { + const app = appFixture(); + const identityCookies = cookieStore([ + sessionCookie("an_session_mail", app.origin), + ]); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const wrongCompletion = new URL( + `${DESKTOP_IDENTITY_COMPLETE_PATH}?nonce=wrong`, + app.origin, + ).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + navigationHandler({}, wrongCompletion, 200, "OK"); + expect(identityCookies.get).not.toHaveBeenCalled(); + closedListener?.(); + + await expect(ceremony).resolves.toBe(false); + expect(app.session.cookies.set).not.toHaveBeenCalled(); + }); + + it("recovers from a missing target cookie without logging session details", async () => { + const app = appFixture(); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const reloadApp = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + sessionCookieWaitMs: 50, + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + navigationHandler({}, completion, 200, "OK"); + + await expect(ceremony).resolves.toBe(false); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("failed"); + expect(warn).toHaveBeenCalledWith( + "[desktop-identity] target session transfer failed", + { appId: "mail" }, + ); + expect(JSON.stringify(warn.mock.calls)).not.toContain("session-value"); + warn.mockRestore(); + }); + + it("does not copy a cookie that appears after the identity window closes", async () => { + const app = appFixture(); + const identityCookies = cookieStore(); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + let closedListener: (() => void) | undefined; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closedListener = listener; + }), + }; + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + sessionCookieWaitMs: 500, + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + navigationHandler({}, completion, 200, "OK"); + await vi.waitFor(() => expect(identityCookies.get).toHaveBeenCalled()); + + closedListener?.(); + await identityCookies.set({ + url: app.origin, + name: "an_session_mail", + value: "late-session", + path: "/", + secure: true, + httpOnly: true, + sameSite: "lax", + }); + + await expect(ceremony).resolves.toBe(false); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(app.session.cookies.set).not.toHaveBeenCalled(); + expect(reloadApp).not.toHaveBeenCalled(); + expect(broker.getStatus()).toBe("sign-in-required"); + }); + + it("does not copy a cookie after the identity ceremony times out", async () => { + const app = appFixture(); + const identityCookies = cookieStore(); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + timeoutMs: 50, + sessionCookieWaitMs: 500, + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + navigationHandler({}, completion, 200, "OK"); + + await expect(ceremony).resolves.toBe(false); + await new Promise((resolve) => setTimeout(resolve, 40)); + expect(app.session.cookies.set).not.toHaveBeenCalled(); + expect(reloadApp).not.toHaveBeenCalled(); + expect(broker.getStatus()).toBe("sign-in-required"); + }); + + it("bounds a cookie-store read that never resolves", async () => { + const app = appFixture(); + const identityCookies = cookieStore(); + identityCookies.get.mockImplementation( + () => new Promise(() => {}), + ); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const reloadApp = vi.fn(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow: () => identityWindow as never, + reloadApp, + clearLocalBroker: vi.fn(), + sessionCookieWaitMs: 25, + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const returnPath = new URL(loadedUrl).searchParams.get("return")!; + const completion = new URL(returnPath, app.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + navigationHandler({}, completion, 200, "OK"); + + await expect(ceremony).resolves.toBe(false); + expect(reloadApp).toHaveBeenCalledWith(app); + expect(broker.getStatus()).toBe("failed"); + warn.mockRestore(); + }); + + it("accepts Dispatch's nonce-bound front-door shortcut without consuming its authority session", async () => { + const dispatch = authorityFixture(); + const identityCookies = cookieStore([ + sessionCookie("an_session_dispatch", dispatch.origin, "dispatch-session"), + sessionCookie("unrelated_cookie", dispatch.origin, "do-not-copy"), + ]); + const resolveLoginRedirect = vi.fn(async (loginUrl: string) => { + const returnPath = new URL(loginUrl).searchParams.get("return")!; + return new URL(returnPath, dispatch.origin).toString(); + }); + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn(), + }; + const createWindow = vi.fn(() => identityWindow as never); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect, + resolveApp: (id) => (id === dispatch.id ? dispatch : null), + createWindow, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(dispatch.id); + await vi.waitFor(() => expect(loadedUrl).not.toBe("")); + const loginUrl = String(resolveLoginRedirect.mock.calls[0]?.[0]); + const returnPath = new URL(loginUrl).searchParams.get("return")!; + const completion = new URL(returnPath, dispatch.origin).toString(); + const navigationHandler = webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + expect(identityCookies.get).not.toHaveBeenCalled(); + navigationHandler({}, completion, 200, "OK"); + + await expect(ceremony).resolves.toBe(true); + + expect(new URL(loginUrl).pathname).toBe("/_agent-native/identity/login"); + expect(new URL(completion).pathname).toBe(DESKTOP_IDENTITY_COMPLETE_PATH); + expect(createWindow).toHaveBeenCalledOnce(); + expect(identityWindow.loadURL).toHaveBeenCalledWith(completion); + expect(dispatch.session.cookies.set).toHaveBeenCalledTimes(1); + expect(dispatch.session.cookies.set).toHaveBeenCalledWith( + expect.objectContaining({ + name: "an_session_dispatch", + value: "dispatch-session", + }), + ); + expect(dispatch.session.cookies.set).not.toHaveBeenCalledWith( + expect.objectContaining({ name: "unrelated_cookie" }), + ); + expect(identityCookies.remove).not.toHaveBeenCalled(); + expect(reloadApp).toHaveBeenCalledWith(dispatch); + expect(broker.getStatus()).toBe("signed-in"); + }); + + it("revokes and clears canonical app sessions plus the central identity session", async () => { + const app = appFixture(); + app.identityAuthority = true; + 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(); + const broker = new DesktopIdentityBroker({ + identitySession, + resolveApp: () => app, + createWindow: vi.fn() as never, + reloadApp, + clearLocalBroker, + }); + + 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"], + }); + expect(app.session.cookies.remove).toHaveBeenCalledTimes(4); + expect(clearLocalBroker).toHaveBeenCalledOnce(); + expect(reloadApp).toHaveBeenCalledWith(app); + 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; + 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 redirectResponse = deferred(); + const resolveLoginRedirect = vi.fn(() => redirectResponse.promise); + const createWindow = vi.fn(); + const reloadApp = vi.fn(); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: cookieStore(), + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveLoginRedirect, + resolveApp: (id) => (id === app.id ? app : null), + createWindow, + reloadApp, + clearLocalBroker: vi.fn(), + }); + + const ceremony = broker.ensureAppSession(app.id); + await vi.waitFor(() => expect(resolveLoginRedirect).toHaveBeenCalled()); + await broker.signOut([app]); + redirectResponse.resolve("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, + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } 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 === "did-navigate", + )?.[1]; + navigationHandler({}, completion, 200, "OK"); + 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); + }); + + it("drains cancelled cookie cleanup before starting an immediate reauthentication", async () => { + const app = appFixture(); + const identityCookies = cookieStore([ + { + name: "an_session_mail", + value: "example-session-value", + domain: "mail.agent-native.com", + hostOnly: true, + path: "/", + secure: true, + httpOnly: true, + session: true, + sameSite: "lax", + }, + ]); + const firstCookieWrite = deferred(); + const targetCookies = cookieStore(); + targetCookies.set + .mockImplementationOnce(async () => firstCookieWrite.promise) + .mockImplementation(async () => {}); + app.session = { + cookies: targetCookies, + fetch: vi.fn(async () => new Response(null, { status: 200 })), + } as unknown as Electron.Session; + const windows: Array<{ + loadedUrl: string; + webContents: { + on: ReturnType; + setWindowOpenHandler: ReturnType; + }; + closed: () => void; + }> = []; + const createWindow = vi.fn(() => { + const webContents = { + on: vi.fn(), + setWindowOpenHandler: vi.fn(), + }; + let loadedUrl = ""; + let closed = () => {}; + const identityWindow = { + webContents, + loadURL: vi.fn(async (url: string) => { + loadedUrl = url; + windows[windows.length - 1]!.loadedUrl = url; + }), + isDestroyed: vi.fn(() => false), + close: vi.fn(), + on: vi.fn((event: string, listener: () => void) => { + if (event === "closed") closed = listener; + windows[windows.length - 1]!.closed = listener; + }), + }; + windows.push({ + loadedUrl, + webContents, + closed: () => closed(), + }); + return identityWindow as never; + }); + const broker = new DesktopIdentityBroker({ + identitySession: { + cookies: identityCookies, + clearStorageData: vi.fn(async () => {}), + } as unknown as Electron.Session, + resolveApp: (id) => (id === app.id ? app : null), + createWindow, + reloadApp: vi.fn(), + clearLocalBroker: vi.fn(), + }); + + const firstCeremony = broker.signIn(app.id); + await vi.waitFor(() => { + expect(windows).toHaveLength(1); + expect(windows[0]!.loadedUrl).not.toBe(""); + }); + const firstReturnPath = new URL(windows[0]!.loadedUrl).searchParams.get( + "return", + )!; + const firstCompletion = new URL(firstReturnPath, app.origin).toString(); + const firstNavigation = windows[0]!.webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + firstNavigation({}, firstCompletion, 200, "OK"); + await vi.waitFor(() => expect(targetCookies.set).toHaveBeenCalledOnce()); + + windows[0]!.closed(); + await expect(firstCeremony).resolves.toBe(false); + const secondCeremony = broker.signIn(app.id); + await Promise.resolve(); + expect(createWindow).toHaveBeenCalledOnce(); + + firstCookieWrite.resolve(); + await vi.waitFor(() => expect(createWindow).toHaveBeenCalledTimes(2)); + expect(targetCookies.remove).toHaveBeenCalledWith( + app.origin, + "an_session_mail", + ); + const secondReturnPath = new URL(windows[1]!.loadedUrl).searchParams.get( + "return", + )!; + const secondCompletion = new URL(secondReturnPath, app.origin).toString(); + const secondNavigation = windows[1]!.webContents.on.mock.calls.find( + ([event]) => event === "did-navigate", + )?.[1]; + secondNavigation({}, secondCompletion, 200, "OK"); + + await expect(secondCeremony).resolves.toBe(true); + expect(targetCookies.set).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/desktop-app/src/main/desktop-identity.ts b/packages/desktop-app/src/main/desktop-identity.ts new file mode 100644 index 0000000000..33aa1d4841 --- /dev/null +++ b/packages/desktop-app/src/main/desktop-identity.ts @@ -0,0 +1,915 @@ +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 DESKTOP_IDENTITY_AUTHORIZE_PATH = "/_agent-native/identity/authorize"; +const DESKTOP_IDENTITY_CALLBACK_PATH = "/_agent-native/identity/callback"; +const DESKTOP_LOGOUT_PATH = "/_agent-native/auth/logout"; +const DESKTOP_LOGOUT_ALL_PATH = "/_agent-native/auth/logout-all"; +const DEFAULT_CEREMONY_TIMEOUT_MS = 5 * 60 * 1000; +const DEFAULT_SESSION_COOKIE_WAIT_MS = 2_000; +const SESSION_COOKIE_POLL_INTERVAL_MS = 25; + +export type DesktopWorkspaceLogoutPath = + | typeof DESKTOP_LOGOUT_PATH + | typeof DESKTOP_LOGOUT_ALL_PATH; + +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 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); + 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 null; + } +} + +interface DesktopIdentityWindow { + webContents: WebContents; + loadURL(url: string): Promise; + isDestroyed(): boolean; + close(): void; + on(event: "closed", listener: () => void): unknown; +} + +export interface DesktopIdentityBrokerOptions { + identitySession: Session; + resolveLoginRedirect?: ( + url: string, + identitySession: Session, + ) => Promise; + 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; + sessionCookieWaitMs?: 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, +): 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 function isDesktopIdentityAuthorizeNavigation( + navigationUrl: string, + authorityApp: DesktopIdentityApp, + targetApp: DesktopIdentityApp, +): boolean { + try { + const parsed = new URL(navigationUrl); + const callback = new URL(parsed.searchParams.get("redirect_uri") ?? ""); + return ( + authorityApp.identityAuthority === true && + parsed.origin === authorityApp.origin && + parsed.pathname === DESKTOP_IDENTITY_AUTHORIZE_PATH && + parsed.searchParams.get("app") === targetApp.id && + Boolean(parsed.searchParams.get("state")) && + callback.origin === targetApp.origin && + callback.pathname === DESKTOP_IDENTITY_CALLBACK_PATH + ); + } catch { + return false; + } +} + +export class DesktopIdentityBroker { + private readonly pendingByApp = new Map>(); + private readonly unsupportedAppIds = new Set(); + private readonly activeSessionCopies = new Set>(); + 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; + + constructor(private readonly options: DesktopIdentityBrokerOptions) {} + + getStatus(): DesktopIdentityStatus { + 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 { + const observedStatus = this.status; + const observedGeneration = this.ceremonyGeneration; + if (observedStatus === "signing-in" || this.signOutOperation) return; + if (!authorityApp) { + this.setStatus("idle"); + return; + } + const cookies = await this.options.identitySession.cookies.get({ + url: authorityApp.origin, + }); + if ( + this.status !== observedStatus || + this.ceremonyGeneration !== observedGeneration || + this.signOutOperation + ) { + return; + } + const allowed = new Set(authorityApp.cookieNames); + this.setStatus( + cookies.some((cookie) => allowed.has(cookie.name)) + ? "signed-in" + : "sign-in-required", + ); + } + + handleSignedOutNavigation(appId: string, navigationUrl: string): boolean { + const app = this.options.resolveApp(appId); + if ( + !app || + this.automaticSignInSuppressed || + 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 generation = this.ceremonyGeneration; + const operation = this.queue.then(async () => { + await this.signOutOperation; + await this.waitForActiveSessionCopies(); + return this.runCeremony(appId, generation); + }); + 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; + } + + signIn(appId: string): Promise { + this.automaticSignInSuppressed = false; + this.unsupportedAppIds.delete(appId); + return this.ensureAppSession(appId); + } + + 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 intent = this.signOutIntent!; + const operation = this.finishSignOut(apps, intent); + this.signOutOperation = operation; + void operation.then( + () => { + if (this.signOutOperation === operation) { + this.signOutOperation = null; + this.resetSignOutState(); + } + }, + () => { + if (this.signOutOperation === operation) { + this.signOutOperation = null; + this.resetSignOutState(); + } + }, + ); + return operation; + } + + 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.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) { + try { + await app.session.cookies.remove(app.origin, cookieName); + } catch (error) { + errors.push(error); + } + } + try { + this.options.reloadApp(app); + } catch (error) { + errors.push(error); + } + } + 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, + ): Promise { + if (!this.isCeremonyCurrent(generation)) return false; + 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 (this.options.resolveLoginRedirect) { + let redirectUrl: string | null; + try { + redirectUrl = await this.options.resolveLoginRedirect( + initialUrl, + this.options.identitySession, + ); + } catch { + if (!this.isCeremonyCurrent(generation)) return false; + console.warn("[desktop-identity] identity preflight failed"); + this.unsupportedAppIds.add(app.id); + this.setStatus("failed"); + this.options.reloadApp(app); + return false; + } + if (!this.isCeremonyCurrent(generation)) return false; + if (!redirectUrl) { + this.unsupportedAppIds.add(app.id); + this.setStatus("failed"); + this.options.reloadApp(app); + return false; + } + initialUrl = redirectUrl; + if (!isDesktopIdentityCompletion(initialUrl, app, nonce)) { + const authorityApp = this.options.resolveApp("dispatch"); + if ( + !authorityApp || + !isDesktopIdentityAuthorizeNavigation(initialUrl, authorityApp, app) + ) { + this.unsupportedAppIds.add(app.id); + this.setStatus("failed"); + this.options.reloadApp(app); + return false; + } + } + } + + if (!this.isCeremonyCurrent(generation)) 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) => { + const ceremonyAbort = new AbortController(); + let settled = false; + let completionStarted = false; + let timer: ReturnType | undefined; + const finish = (ok: boolean, status: DesktopIdentityStatus) => { + if (settled) return; + settled = true; + if (!ok) ceremonyAbort.abort(); + if (timer) clearTimeout(timer); + if (this.activeWindow === identityWindow) this.activeWindow = null; + if (this.isCeremonyCurrent(generation)) this.setStatus(status); + if (!identityWindow.isDestroyed()) identityWindow.close(); + resolve(ok); + }; + + const inspectNavigation = (event: Electron.Event, url: string) => { + if (isDesktopIdentityCompletion(url, app, nonce)) { + return; + } + if ( + this.options.handleOAuthNavigation?.(url, identityWindow.webContents) + ) { + event.preventDefault(); + } + }; + + identityWindow.webContents.on("will-navigate", inspectNavigation); + identityWindow.webContents.on("will-redirect", (event, url) => + inspectNavigation(event, url), + ); + identityWindow.webContents.on( + "did-navigate", + (_event, url, httpResponseCode) => { + if ( + settled || + completionStarted || + !isDesktopIdentityCompletion(url, app, nonce) + ) { + return; + } + completionStarted = true; + if (!this.isCeremonyCurrent(generation)) { + finish(false, "sign-in-required"); + return; + } + if (httpResponseCode !== 200) { + console.warn("[desktop-identity] authenticated completion failed", { + appId: app.id, + statusCode: httpResponseCode, + }); + this.options.reloadApp(app); + finish(false, "failed"); + return; + } + void this.trackSessionCopy( + this.copyTargetSession(app, generation, ceremonyAbort.signal), + ).then( + () => { + if (!this.isCeremonyCurrent(generation)) { + finish(false, "sign-in-required"); + return; + } + this.options.reloadApp(app); + finish(true, "signed-in"); + }, + () => { + if (ceremonyAbort.signal.aborted) return; + this.recoverFromSessionCopyFailure(app, generation); + finish(false, "failed"); + }, + ); + }, + ); + 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, + generation: number, + signal?: AbortSignal, + ): Promise { + this.assertCeremonyActive(generation, signal); + const allowed = new Set(app.cookieNames); + const deadline = + Date.now() + + (this.options.sessionCookieWaitMs ?? DEFAULT_SESSION_COOKIE_WAIT_MS); + let cookies: Electron.Cookie[] = []; + do { + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) break; + const sourceCookies = await this.readIdentityCookies( + app.origin, + remainingMs, + signal, + ); + this.assertCeremonyActive(generation, signal); + cookies = sourceCookies.filter((cookie) => allowed.has(cookie.name)); + if (cookies.length > 0 || Date.now() >= deadline) break; + await this.waitForCookiePoll( + Math.min(SESSION_COOKIE_POLL_INTERVAL_MS, deadline - Date.now()), + signal, + ); + this.assertCeremonyActive(generation, signal); + } while (true); + if (cookies.length === 0) throw new Error("Missing app session cookie"); + + const writtenCookieNames: string[] = []; + try { + for (const cookieName of app.cookieNames) { + this.assertCeremonyActive(generation, signal); + await app.session.cookies + .remove(app.origin, cookieName) + .catch(() => {}); + } + for (const cookie of cookies) { + this.assertCeremonyActive(generation, signal); + await app.session.cookies.set({ + url: app.origin, + name: cookie.name, + value: cookie.value, + path: cookie.path || "/", + httpOnly: cookie.httpOnly, + secure: cookie.secure, + sameSite: cookie.sameSite, + ...(cookie.expirationDate + ? { expirationDate: cookie.expirationDate } + : {}), + }); + writtenCookieNames.push(cookie.name); + this.assertCeremonyActive(generation, signal); + } + + if (!app.identityAuthority) { + for (const cookie of cookies) { + this.assertCeremonyActive(generation, signal); + await this.options.identitySession.cookies + .remove(app.origin, cookie.name) + .catch(() => {}); + this.assertCeremonyActive(generation, signal); + } + } + this.assertCeremonyActive(generation, signal); + } catch (error) { + if (!this.isCeremonyCurrent(generation) || signal?.aborted) { + await Promise.all( + writtenCookieNames.map((cookieName) => + app.session.cookies.remove(app.origin, cookieName).catch(() => {}), + ), + ); + } + throw error; + } + } + + private async readIdentityCookies( + origin: string, + timeoutMs: number, + signal?: AbortSignal, + ): Promise { + let timeout: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const stopped = new Promise((_resolve, reject) => { + timeout = setTimeout( + () => reject(new Error("Identity cookie read timed out")), + timeoutMs, + ); + if (signal) { + onAbort = () => + reject(new Error("Desktop identity ceremony was cancelled")); + signal.addEventListener("abort", onAbort, { once: true }); + } + }); + try { + return await Promise.race([ + this.options.identitySession.cookies.get({ url: origin }), + stopped, + ]); + } finally { + if (timeout) clearTimeout(timeout); + if (signal && onAbort) signal.removeEventListener("abort", onAbort); + } + } + + private async waitForCookiePoll( + delayMs: number, + signal?: AbortSignal, + ): Promise { + if (delayMs <= 0) return; + await new Promise((resolve, reject) => { + let settled = false; + let timeout: ReturnType | undefined; + const cleanup = () => { + if (timeout) clearTimeout(timeout); + signal?.removeEventListener("abort", onAbort); + }; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(); + }; + const onAbort = () => { + if (settled) return; + settled = true; + cleanup(); + reject(new Error("Desktop identity ceremony was cancelled")); + }; + timeout = setTimeout(finish, delayMs); + if (signal?.aborted) { + onAbort(); + return; + } + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + private recoverFromSessionCopyFailure( + app: DesktopIdentityApp, + generation: number, + ): void { + if (!this.isCeremonyCurrent(generation)) return; + console.warn("[desktop-identity] target session transfer failed", { + appId: app.id, + }); + this.options.reloadApp(app); + } + + private closeActiveWindow(): void { + const active = this.activeWindow; + this.activeWindow = null; + 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 assertCeremonyActive(generation: number, signal?: AbortSignal): void { + this.assertCeremonyCurrent(generation); + if (signal?.aborted) { + throw new Error("Desktop identity ceremony was cancelled"); + } + } + + private setStatus(status: DesktopIdentityStatus): void { + this.status = status; + this.options.onStatus?.(status); + } +} diff --git a/packages/desktop-app/src/main/desktop-startup.spec.ts b/packages/desktop-app/src/main/desktop-startup.spec.ts new file mode 100644 index 0000000000..f7ee99cfd1 --- /dev/null +++ b/packages/desktop-app/src/main/desktop-startup.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from "vitest"; + +import { initializeDesktopStartup } from "./desktop-startup.js"; + +function createDependencies( + overrides: Partial[0]> = {}, +) { + const events: string[] = []; + return { + events, + dependencies: { + isPackaged: true, + version: "0.1.150-desktop-sso-canary.20", + appDataPath: "/application-support", + createDirectory: vi.fn(() => events.push("create-directory")), + setUserDataPath: vi.fn(() => events.push("set-user-data")), + initializeSentry: vi.fn(() => events.push("sentry")), + initializeLogger: vi.fn(() => events.push("logger")), + logError: vi.fn(), + logWarning: vi.fn(), + ...overrides, + }, + }; +} + +describe("initializeDesktopStartup", () => { + it("isolates a packaged canary before initializing profile consumers", () => { + const { dependencies, events } = createDependencies(); + + initializeDesktopStartup(dependencies); + + expect(dependencies.createDirectory).toHaveBeenCalledWith( + "/application-support/Agent Native SSO Canary", + ); + expect(dependencies.setUserDataPath).toHaveBeenCalledWith( + "/application-support/Agent Native SSO Canary", + ); + expect(events).toEqual([ + "create-directory", + "set-user-data", + "sentry", + "logger", + ]); + }); + + it("aborts packaged canary startup when profile isolation fails", () => { + const failure = new Error("read-only volume"); + const { dependencies } = createDependencies({ + createDirectory: vi.fn(() => { + throw failure; + }), + }); + + expect(() => initializeDesktopStartup(dependencies)).toThrow(failure); + expect(dependencies.logError).toHaveBeenCalledWith( + "[main] failed to isolate packaged userData directory:", + failure, + ); + expect(dependencies.setUserDataPath).not.toHaveBeenCalled(); + expect(dependencies.initializeSentry).not.toHaveBeenCalled(); + expect(dependencies.initializeLogger).not.toHaveBeenCalled(); + }); + + it("preserves stable Desktop's existing profile", () => { + const { dependencies, events } = createDependencies({ + version: "0.1.150", + }); + + initializeDesktopStartup(dependencies); + + expect(dependencies.createDirectory).not.toHaveBeenCalled(); + expect(dependencies.setUserDataPath).not.toHaveBeenCalled(); + expect(events).toEqual(["sentry", "logger"]); + }); + + it("keeps development startup recoverable when profile isolation fails", () => { + const failure = new Error("read-only volume"); + const { dependencies, events } = createDependencies({ + isPackaged: false, + createDirectory: vi.fn(() => { + throw failure; + }), + }); + + initializeDesktopStartup(dependencies); + + expect(dependencies.logWarning).toHaveBeenCalledWith( + "[main] failed to isolate userData directory:", + failure, + ); + expect(events).toEqual(["sentry", "logger"]); + }); +}); diff --git a/packages/desktop-app/src/main/desktop-startup.ts b/packages/desktop-app/src/main/desktop-startup.ts new file mode 100644 index 0000000000..e86bb0310f --- /dev/null +++ b/packages/desktop-app/src/main/desktop-startup.ts @@ -0,0 +1,54 @@ +import path from "node:path"; + +import { resolveDesktopUserDataDirectoryName } from "./ipc/update-policy.js"; + +export interface DesktopStartupDependencies { + isPackaged: boolean; + version: string; + appDataPath: string; + createDirectory: (directoryPath: string) => void; + setUserDataPath: (directoryPath: string) => void; + initializeSentry: () => void; + initializeLogger: () => void; + logError: (message: string, error: unknown) => void; + logWarning: (message: string, error: unknown) => void; +} + +export function initializeDesktopStartup({ + isPackaged, + version, + appDataPath, + createDirectory, + setUserDataPath, + initializeSentry, + initializeLogger, + logError, + logWarning, +}: DesktopStartupDependencies): void { + const isolatedUserDataDirectoryName = resolveDesktopUserDataDirectoryName( + isPackaged, + version, + ); + if (isolatedUserDataDirectoryName) { + const isolatedUserDataPath = path.join( + appDataPath, + isolatedUserDataDirectoryName, + ); + try { + createDirectory(isolatedUserDataPath); + setUserDataPath(isolatedUserDataPath); + } catch (error) { + if (isPackaged) { + logError( + "[main] failed to isolate packaged userData directory:", + error, + ); + throw error; + } + logWarning("[main] failed to isolate userData directory:", error); + } + } + + initializeSentry(); + initializeLogger(); +} diff --git a/packages/desktop-app/src/main/index.ts b/packages/desktop-app/src/main/index.ts index ba4b73d256..1d93e89879 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, @@ -110,6 +112,7 @@ import { globalShortcut, ipcMain, Menu, + net, Notification, session, shell, @@ -168,12 +171,20 @@ import { SwiftDesktopHelperClient, } from "./computer-control"; import { DesktopDesignPreviewManager } from "./design-preview-manager"; +import { + DESKTOP_IDENTITY_PARTITION, + DesktopIdentityBroker, + desktopWorkspaceLogoutPath, + isDesktopIdentityConfiguredAppEligible, + type DesktopIdentityApp, +} from "./desktop-identity"; import { captureWebviewLogs, initializeDesktopLogger, revealLogFolder, getLogFilePath, } from "./desktop-logger"; +import { initializeDesktopStartup } from "./desktop-startup.js"; import { registerAppsIpc } from "./ipc/apps"; import { registerCodeAgentsIpc } from "./ipc/code-agents"; import { registerContentFilesIpc } from "./ipc/content-files"; @@ -197,9 +208,20 @@ import { installSentryWebContentsInstrumentation, setSentryWebContentsMetadata, } from "./sentry"; - -initializeDesktopSentry(); -initializeDesktopLogger(); +import { installWebviewNavigationListeners } from "./webview-navigation"; + +initializeDesktopStartup({ + isPackaged: app.isPackaged, + version: app.getVersion(), + appDataPath: app.getPath("appData"), + createDirectory: (directoryPath) => + fs.mkdirSync(directoryPath, { recursive: true }), + setUserDataPath: (directoryPath) => app.setPath("userData", directoryPath), + initializeSentry: initializeDesktopSentry, + initializeLogger: initializeDesktopLogger, + logError: console.error, + logWarning: console.warn, +}); const DESKTOP_CODE_AGENT_PERSISTENCE_LOCK = { lockWaitMs: 50, @@ -230,19 +252,6 @@ process.on("uncaughtException", (err: NodeJS.ErrnoException) => { const IS_DEV = !app.isPackaged; -if (IS_DEV) { - // Keep local electron-vite runs out of the packaged app's Chromium profile. - // Sharing the same userData directory lets dev and prod processes fight over - // persisted webview storage (notably IndexedDB LevelDB LOCK files). - const devUserDataPath = path.join(app.getPath("appData"), "Agent Native Dev"); - try { - fs.mkdirSync(devUserDataPath, { recursive: true }); - app.setPath("userData", devUserDataPath); - } catch (err) { - console.warn("[main] failed to isolate dev userData directory:", err); - } -} - // ---------- User-Agent marker ---------- // Tag every request from this Electron app so the server can distinguish // Agent Native desktop from other Electron-based webviews (Builder.io's @@ -269,6 +278,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; @@ -546,6 +557,110 @@ function getInjectionTargetForAppId( }; } +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, + ); + if ( + !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_/, ""); + 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 listDesktopIdentityCleanupApps(): DesktopIdentityApp[] { + return DESKTOP_DEFAULT_APPS.map((candidate) => + resolveDesktopIdentityApp(candidate.id, { forCleanup: true }), + ).filter((candidate): candidate is DesktopIdentityApp => candidate !== null); +} + +function resolveDesktopIdentityLoginRedirect( + requestUrl: string, + identitySession: Electron.Session, +): Promise { + return new Promise((resolve, reject) => { + const request = net.request({ + url: requestUrl, + session: identitySession, + redirect: "manual", + }); + let settled = false; + let timer: ReturnType | undefined; + const finish = (redirectUrl: string | null) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + resolve(redirectUrl); + }; + const fail = (error: Error) => { + if (settled) return; + settled = true; + if (timer) clearTimeout(timer); + reject(error); + }; + + request.on("redirect", (_statusCode, _method, redirectUrl) => { + finish(redirectUrl); + request.abort(); + }); + request.on("response", (response) => { + response.on("data", () => {}); + response.on("end", () => finish(null)); + response.on("error", fail); + }); + request.on("error", fail); + timer = setTimeout(() => { + request.abort(); + fail(new Error("Identity redirect preflight timed out")); + }, 15_000); + request.end(); + }); +} + function getOAuthInjectionTarget( sourceSession: Electron.Session | undefined, sourceUrl: string | undefined, @@ -970,6 +1085,29 @@ 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, async (event) => { + if (!isShellIdentityIpc(event)) + return "failed" satisfies DesktopIdentityStatus; + await desktopIdentityBroker?.refreshStatus( + resolveDesktopIdentityApp("dispatch"), + ); + return desktopIdentityBroker?.getStatus() ?? "idle"; +}); + +ipcMain.handle(IPC.IDENTITY_SIGN_OUT, async (event) => { + if (!isShellIdentityIpc(event) || !desktopIdentityBroker) return false; + await desktopIdentityBroker.signOut(listDesktopIdentityCleanupApps()); + return true; +}); + function createWindow(): BrowserWindow { if (mainWindow && !mainWindow.isDestroyed()) { return mainWindow; @@ -8750,6 +8888,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; @@ -8772,17 +8919,7 @@ function installWebviewOAuthNavigationHandler(contents: Electron.WebContents) { } }; - contents.on("will-frame-navigate", (event) => { - if (event.isMainFrame) return; - handleNavigation(event, event.url, { isMainFrame: false }); - }); - - // Belt-and-suspenders for existing deployed app bundles that may still - // fall back to assigning window.location when Electron reports a manually - // handled popup as null. Keep Builder/Google OAuth out of the app webview. - contents.on("will-navigate", (event) => { - handleNavigation(event, event.url, { isMainFrame: true }); - }); + installWebviewNavigationListeners(contents, handleNavigation); } // ---------- Webview popup handling ---------- @@ -8913,15 +9050,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 @@ -8987,6 +9124,12 @@ function installApplicationMenu() { buildUpdateMenuItem(), buildCurrentVersionMenuItem(), { type: "separator" as const }, + { + label: "Sign Out of Agent Native", + click: () => + void desktopIdentityBroker?.signOut(listDesktopIdentityCleanupApps()), + }, + { type: "separator" as const }, { role: "services" as const }, { type: "separator" as const }, { role: "hide" as const }, @@ -9167,6 +9310,38 @@ function configurePermissionHandlers( } app.whenReady().then(async () => { + desktopIdentityBroker = new DesktopIdentityBroker({ + identitySession: session.fromPartition(DESKTOP_IDENTITY_PARTITION), + resolveLoginRedirect: resolveDesktopIdentityLoginRedirect, + resolveApp: resolveDesktopIdentityApp, + createWindow: (options) => new BrowserWindow(options), + parentWindow: () => mainWindow, + 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) { @@ -9205,8 +9380,51 @@ 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; + const logoutPath = identityApp + ? desktopWorkspaceLogoutPath(details.url, identityApp) + : null; + if ( + identityApp && + logoutPath && + details.method === "POST" && + desktopIdentityBroker && + !desktopIdentityBroker.isInternalRevocationRequest(details.url) + ) { + 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/`) + ) { + callback({}); + return; + } let apps: AppConfig[] = []; try { apps = AppStore.loadApps(); @@ -9231,6 +9449,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 @@ -9264,6 +9544,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/main/ipc/update-policy.spec.ts b/packages/desktop-app/src/main/ipc/update-policy.spec.ts new file mode 100644 index 0000000000..31ee02abc5 --- /dev/null +++ b/packages/desktop-app/src/main/ipc/update-policy.spec.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + resolveDesktopUpdateSupport, + resolveDesktopUserDataDirectoryName, +} 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, + }); + }); + + it("isolates development and Desktop SSO canary profiles from stable Desktop", () => { + expect(resolveDesktopUserDataDirectoryName(false, "0.1.150")).toBe( + "Agent Native Dev", + ); + expect( + resolveDesktopUserDataDirectoryName( + true, + "0.1.150-desktop-sso-canary.19", + ), + ).toBe("Agent Native SSO Canary"); + expect(resolveDesktopUserDataDirectoryName(true, "0.1.150")).toBeNull(); + expect( + resolveDesktopUserDataDirectoryName( + true, + "0.1.150-desktop-sso-canary.not-a-run", + ), + ).toBeNull(); + }); +}); diff --git a/packages/desktop-app/src/main/ipc/update-policy.ts b/packages/desktop-app/src/main/ipc/update-policy.ts new file mode 100644 index 0000000000..724e0db055 --- /dev/null +++ b/packages/desktop-app/src/main/ipc/update-policy.ts @@ -0,0 +1,39 @@ +const DESKTOP_SSO_CANARY_VERSION = /-desktop-sso-canary\.\d+$/; + +export function isDesktopSsoCanaryVersion(version: string): boolean { + return DESKTOP_SSO_CANARY_VERSION.test(version); +} + +export function resolveDesktopUserDataDirectoryName( + isPackaged: boolean, + version: string, +): string | null { + if (!isPackaged) return "Agent Native Dev"; + if (isDesktopSsoCanaryVersion(version)) return "Agent Native SSO Canary"; + return null; +} + +export type DesktopUpdateSupport = + | { supported: true } + | { supported: false; reason: string }; + +export function resolveDesktopUpdateSupport( + isPackaged: boolean, + version: string, +): DesktopUpdateSupport { + if (!isPackaged) { + return { + supported: false, + reason: "Auto-update is disabled in development", + }; + } + + if (isDesktopSsoCanaryVersion(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 index 99e158dd5a..b4bf96f121 100644 --- a/packages/desktop-app/src/main/ipc/updates.spec.ts +++ b/packages/desktop-app/src/main/ipc/updates.spec.ts @@ -1,3 +1,4 @@ +import { IPC } from "@shared/ipc-channels"; import { beforeEach, describe, expect, it, vi } from "vitest"; const electronState = vi.hoisted(() => { @@ -62,23 +63,32 @@ vi.mock("electron", () => ({ vi.mock("electron-updater", () => ({ autoUpdater: updaterState })); -import { IPC } from "@shared/ipc-channels"; - let checkForAppUpdates: typeof import("./updates.js").checkForAppUpdates; let getCurrentUpdateStatus: typeof import("./updates.js").getCurrentUpdateStatus; let registerUpdatesIpc: typeof import("./updates.js").registerUpdatesIpc; +async function reloadUpdates() { + ({ checkForAppUpdates, getCurrentUpdateStatus, registerUpdatesIpc } = + await import("./updates.js")); +} + describe("desktop updates", () => { beforeEach(async () => { vi.clearAllMocks(); + electronState.app.isPackaged = true; + electronState.app.getVersion.mockReturnValue("1.0.0"); + electronState.app.whenReady.mockImplementation( + () => new Promise(() => {}), + ); electronState.ipcMain.handlers.clear(); updaterState.handlers.clear(); + updaterState.autoDownload = false; + updaterState.autoInstallOnAppQuit = false; updaterState.checkForUpdates.mockReset(); updaterState.downloadUpdate.mockReset(); electronState.notification.isSupported.mockReturnValue(false); vi.resetModules(); - ({ checkForAppUpdates, getCurrentUpdateStatus, registerUpdatesIpc } = - await import("./updates.js")); + await reloadUpdates(); }); it("shows a clear result when a manual check finds no update", async () => { @@ -141,4 +151,71 @@ describe("desktop updates", () => { }); expect(electronState.ipcMain.handlers.has(IPC.UPDATE_INSTALL)).toBe(true); }); + + it("gives a Desktop SSO canary no updater network, download, install, or result-notification capability", async () => { + electronState.app.getVersion.mockReturnValue( + "0.1.150-desktop-sso-canary.4", + ); + electronState.notification.isSupported.mockReturnValue(true); + vi.resetModules(); + await reloadUpdates(); + const intervalSpy = vi.spyOn(globalThis, "setInterval"); + + registerUpdatesIpc({ + refreshApplicationMenu: vi.fn(), + focusMainWindow: vi.fn(), + }); + + expect(getCurrentUpdateStatus()).toEqual({ + state: "unsupported", + reason: "Auto-update is disabled for this Desktop SSO canary build", + }); + expect(updaterState.setFeedURL).not.toHaveBeenCalled(); + expect(updaterState.on).not.toHaveBeenCalled(); + expect(updaterState.checkForUpdates).not.toHaveBeenCalled(); + expect(electronState.app.whenReady).not.toHaveBeenCalled(); + expect(intervalSpy).not.toHaveBeenCalled(); + + await electronState.ipcMain.handlers.get(IPC.UPDATE_CHECK)?.(); + await electronState.ipcMain.handlers.get(IPC.UPDATE_DOWNLOAD)?.(); + electronState.ipcMain.handlers.get(IPC.UPDATE_INSTALL)?.(); + + expect(updaterState.checkForUpdates).not.toHaveBeenCalled(); + expect(updaterState.downloadUpdate).not.toHaveBeenCalled(); + expect(updaterState.quitAndInstall).not.toHaveBeenCalled(); + expect(electronState.notification).not.toHaveBeenCalled(); + intervalSpy.mockRestore(); + }); + + it.each(["0.1.150", "0.1.150-beta.4"])( + "preserves updater setup for %s", + async (version) => { + electronState.app.getVersion.mockReturnValue(version); + electronState.app.whenReady.mockResolvedValue(); + updaterState.checkForUpdates.mockResolvedValue(undefined); + vi.resetModules(); + await reloadUpdates(); + const intervalSpy = vi + .spyOn(globalThis, "setInterval") + .mockReturnValue({} as NodeJS.Timeout); + + registerUpdatesIpc({ + refreshApplicationMenu: vi.fn(), + focusMainWindow: vi.fn(), + }); + await vi.waitFor(() => { + expect(updaterState.checkForUpdates).toHaveBeenCalledOnce(); + }); + + expect(updaterState.setFeedURL).toHaveBeenCalledWith({ + provider: "generic", + url: "https://agent-native.com/api/desktop-updates", + }); + expect(updaterState.on).toHaveBeenCalled(); + expect(updaterState.autoDownload).toBe(true); + expect(updaterState.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 dd3f4f0f57..913b501275 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; @@ -91,7 +98,7 @@ async function waitForDownloadedUpdate( export async function checkForAppUpdates( options: UpdateCheckOptions = {}, ): Promise { - if (IS_DEV) return currentUpdateStatus; + if (!UPDATE_SUPPORT.supported) return currentUpdateStatus; if (currentUpdateStatus.state === "downloaded") return currentUpdateStatus; if (!updateCheckInFlight) { @@ -120,7 +127,7 @@ export async function checkForAppUpdates( } function maybeCheckForAppUpdates() { - if (IS_DEV) return; + if (!UPDATE_SUPPORT.supported) return; if (currentUpdateStatus.state === "downloaded") return; if ( updateCheckInFlight || @@ -174,7 +181,7 @@ function showUpdateCheckResultNotification(status: UpdateStatus) { 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. @@ -253,7 +260,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 waitForDownloadedUpdate(autoUpdater.downloadUpdate()); } catch (err) { @@ -267,7 +274,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/packages/desktop-app/src/main/multi-frontier-app-integration.spec.ts b/packages/desktop-app/src/main/multi-frontier-app-integration.spec.ts index 326da11e90..f5836405db 100644 --- a/packages/desktop-app/src/main/multi-frontier-app-integration.spec.ts +++ b/packages/desktop-app/src/main/multi-frontier-app-integration.spec.ts @@ -89,15 +89,49 @@ describe("multi-frontier app integration", () => { expect(guard(event)).toBe(true); expect(guard(event)).toBe(true); + await Promise.resolve(); expect(dispose).toHaveBeenCalledOnce(); settle(); - await Promise.resolve(); - await Promise.resolve(); + await vi.waitFor(() => expect(reissueQuit).toHaveBeenCalledOnce()); + expect(guard(event)).toBe(false); + }); - expect(reissueQuit).toHaveBeenCalledOnce(); + it("reissues quit when disposal throws synchronously", async () => { + const reissueQuit = vi.fn(); + const guard = createMultiFrontierQuitGuard({ + dispose: vi.fn(() => { + throw new Error("dispose failed"); + }), + reissueQuit, + }); + const event = { preventDefault: vi.fn() }; + + expect(guard(event)).toBe(true); + await vi.waitFor(() => expect(reissueQuit).toHaveBeenCalledOnce()); expect(guard(event)).toBe(false); }); + it("bounds disposal so application quit cannot hang indefinitely", async () => { + vi.useFakeTimers(); + try { + const reissueQuit = vi.fn(); + const guard = createMultiFrontierQuitGuard({ + dispose: vi.fn(() => new Promise(() => undefined)), + reissueQuit, + disposeTimeoutMs: 25, + }); + const event = { preventDefault: vi.fn() }; + + expect(guard(event)).toBe(true); + await vi.advanceTimersByTimeAsync(25); + + expect(reissueQuit).toHaveBeenCalledOnce(); + expect(guard(event)).toBe(false); + } finally { + vi.useRealTimers(); + } + }); + it("stores an immutable private patch with actual diff-check evidence and no all-zero hash", async () => { const workspace = root(); fs.writeFileSync( diff --git a/packages/desktop-app/src/main/multi-frontier-app-integration.ts b/packages/desktop-app/src/main/multi-frontier-app-integration.ts index dae78566fd..4e7df3f6ed 100644 --- a/packages/desktop-app/src/main/multi-frontier-app-integration.ts +++ b/packages/desktop-app/src/main/multi-frontier-app-integration.ts @@ -24,6 +24,7 @@ const execFileAsync = promisify(execFile); const MAX_EVIDENCE_BYTES = 16 * 1024; const MAX_GIT_OUTPUT_BYTES = 8 * 1024; const MAX_SNAPSHOT_PATCH_BYTES = 256 * 1024; +const DEFAULT_QUIT_DISPOSE_TIMEOUT_MS = 5_000; const SAFE_WORKSPACE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,191}$/; const NULL_DEVICE = process.platform === "win32" ? "NUL" : "/dev/null"; @@ -149,6 +150,7 @@ export function initializeMultiFrontierAppIntegration( export function createMultiFrontierQuitGuard(options: { dispose(): Promise; reissueQuit(): void; + disposeTimeoutMs?: number; }): (event: MultiFrontierQuitEvent) => boolean { let reissued = false; let disposing: Promise | undefined; @@ -156,13 +158,21 @@ export function createMultiFrontierQuitGuard(options: { if (reissued) return false; event.preventDefault(); if (!disposing) { - disposing = options - .dispose() - .catch(() => undefined) - .finally(() => { - reissued = true; - options.reissueQuit(); - }); + const disposal = Promise.resolve() + .then(() => options.dispose()) + .catch(() => undefined); + let timeoutHandle: ReturnType | undefined; + const timeout = new Promise((resolve) => { + timeoutHandle = setTimeout( + resolve, + options.disposeTimeoutMs ?? DEFAULT_QUIT_DISPOSE_TIMEOUT_MS, + ); + }); + disposing = Promise.race([disposal, timeout]).finally(() => { + if (timeoutHandle) clearTimeout(timeoutHandle); + reissued = true; + options.reissueQuit(); + }); } return true; }; diff --git a/packages/desktop-app/src/main/webview-navigation.spec.ts b/packages/desktop-app/src/main/webview-navigation.spec.ts new file mode 100644 index 0000000000..c0a2d1b6bf --- /dev/null +++ b/packages/desktop-app/src/main/webview-navigation.spec.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +import { installWebviewNavigationListeners } from "./webview-navigation"; + +describe("webview navigation listeners", () => { + it("forwards main-frame navigation from will-frame-navigate", () => { + const listeners = new Map void>(); + const contents = { + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + } as unknown as Electron.WebContents; + const handleNavigation = vi.fn(); + installWebviewNavigationListeners(contents, handleNavigation); + + const event = { + isMainFrame: true, + preventDefault: vi.fn(), + url: "https://mail.agent-native.com/_agent-native/sign-in", + }; + listeners.get("will-frame-navigate")?.(event as never); + + expect(handleNavigation).toHaveBeenCalledWith(event, event.url, { + isMainFrame: true, + }); + }); + + it("forwards the legacy will-navigate URL when the event has no URL", () => { + const listeners = new Map void>(); + const contents = { + on: vi.fn((event: string, listener: (...args: never[]) => void) => { + listeners.set(event, listener); + }), + } as unknown as Electron.WebContents; + const handleNavigation = vi.fn(); + installWebviewNavigationListeners(contents, handleNavigation); + + const event = { preventDefault: vi.fn() }; + const url = + "https://mail.agent-native.com/_agent-native/sign-in?return=%2Finbox"; + listeners.get("will-navigate")?.(event as never, url as never); + + expect(handleNavigation).toHaveBeenCalledWith(event, url, { + isMainFrame: true, + }); + }); +}); diff --git a/packages/desktop-app/src/main/webview-navigation.ts b/packages/desktop-app/src/main/webview-navigation.ts new file mode 100644 index 0000000000..69d4f28cee --- /dev/null +++ b/packages/desktop-app/src/main/webview-navigation.ts @@ -0,0 +1,23 @@ +export type WebviewNavigationHandler = ( + event: Electron.Event, + url: string, + options: { isMainFrame: boolean }, +) => void; + +export function installWebviewNavigationListeners( + contents: Electron.WebContents, + handleNavigation: WebviewNavigationHandler, +) { + contents.on("will-frame-navigate", (event) => { + handleNavigation(event, event.url, { isMainFrame: event.isMainFrame }); + }); + + // Retain the legacy positional URL fallback for webview navigation events. + contents.on("will-navigate", (event, url) => { + handleNavigation(event, url || event.url, { isMainFrame: true }); + }); + + contents.on("will-redirect", (event, url, _isInPlace, isMainFrame) => { + handleNavigation(event, url, { isMainFrame }); + }); +} diff --git a/packages/desktop-app/src/preload/index.spec.ts b/packages/desktop-app/src/preload/index.spec.ts index 9f8a36887c..bf4b6d626b 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,39 @@ 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; + signOut(): Promise; + onStatusChange(callback: (status: string) => void): () => void; + }; + } + ).identity; + + await api.getStatus(); + await api.signOut(); + expect(electron.invoke).toHaveBeenCalledWith(IPC.IDENTITY_STATUS_GET); + 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..1b313beb37 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,24 @@ const electronAPI = { }, }, + /** Workspace identity commands expose intent and status, never credentials. */ + identity: { + getStatus: (): Promise => + ipcRenderer.invoke(IPC.IDENTITY_STATUS_GET), + 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/App.spec.tsx b/packages/desktop-app/src/renderer/App.spec.tsx new file mode 100644 index 0000000000..255893dc2f --- /dev/null +++ b/packages/desktop-app/src/renderer/App.spec.tsx @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom + +import { DESKTOP_DEFAULT_APPS } from "@shared/app-registry"; +import React, { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("sonner", () => ({ + Toaster: () => null, + toast: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn() }), +})); + +vi.mock("./components/AppSettings.js", () => ({ + default: ({ onClose }: { onClose: () => void }) => ( +
+ App Settings + +
+ ), + AddAppDialog: () => null, + AppEditForm: () => null, +})); + +vi.mock("./components/AppWebview.js", async () => { + const react = await import("react"); + return { + default: react.forwardRef(({ isActive }: { isActive: boolean }) => ( +
+ Still loading +
+ )), + }; +}); + +vi.mock("./components/CodeAgentsHub.js", () => ({ default: () => null })); +vi.mock("./components/TabBar.js", () => ({ default: () => null })); +vi.mock("./components/UpdatePrompt.js", () => ({ default: () => null })); +vi.mock("./components/UpdateIndicator.js", () => ({ + UpdateIndicator: () => null, +})); + +import App from "./App.js"; + +describe("Desktop shell Settings boundary", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + window.electronAPI = { + appConfig: { + load: vi.fn(async () => [ + { ...DESKTOP_DEFAULT_APPS.find((app) => app.id === "mail")! }, + ]), + }, + frame: { + load: vi.fn(async () => ({ + enabled: false, + showCodeTab: false, + mode: "prod" as const, + })), + }, + setActiveApp: vi.fn(), + } as unknown as ElectronAPI; + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + it("opens Settings while the active app is still loading", async () => { + await act(async () => { + root.render(); + await Promise.resolve(); + }); + await act(async () => { + await Promise.resolve(); + }); + + const slowWebview = container.querySelector('[data-testid="slow-webview"]'); + expect(slowWebview?.getAttribute("data-active")).toBe("true"); + const contentArea = container.querySelector(".content-area"); + expect( + contentArea?.classList.contains("content-area--shell-modal-open"), + ).toBe(false); + const settings = container.querySelector( + 'button[aria-label="Settings"]', + ); + expect(settings).not.toBeNull(); + + act(() => settings?.click()); + + expect(container.querySelector('[role="dialog"]')?.textContent).toBe( + "App SettingsClose Settings", + ); + expect(slowWebview?.getAttribute("data-active")).toBe("false"); + expect( + contentArea?.classList.contains("content-area--shell-modal-open"), + ).toBe(true); + + const closeSettings = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Close Settings", + ); + expect(closeSettings).not.toBeNull(); + act(() => closeSettings?.click()); + + expect(container.querySelector('[role="dialog"]')).toBeNull(); + expect(container.querySelector('[data-testid="slow-webview"]')).toBe( + slowWebview, + ); + expect(slowWebview?.getAttribute("data-active")).toBe("true"); + expect( + contentArea?.classList.contains("content-area--shell-modal-open"), + ).toBe(false); + }); +}); diff --git a/packages/desktop-app/src/renderer/App.tsx b/packages/desktop-app/src/renderer/App.tsx index 5cb6308511..7db81120ef 100644 --- a/packages/desktop-app/src/renderer/App.tsx +++ b/packages/desktop-app/src/renderer/App.tsx @@ -839,6 +839,8 @@ export default function App() { showCodeAgentsTab && activeSidebarAppId === CODE_AGENTS_SURFACE_ID; const shouldRenderCodeAgents = showCodeAgentsTab && (isCodeAgentsActive || hasMountedCodeAgents); + const shellModalOpen = + showSettings || showAddApp || editingSidebarAppId !== null; // Keep app webviews warm once visited so switching apps feels like browser // tabs: the guest page remains alive offscreen and keeps its runtime state. @@ -962,7 +964,7 @@ export default function App() {
{shouldRenderCodeAgents && (
setShowSettings(true)} @@ -990,11 +992,11 @@ export default function App() { }} app={appDef} appConfig={app} - isActive={isActive} + isActive={isActive && !shellModalOpen} urlOpenNonce={tab.urlOpenNonce} urlPath={tab.urlPath} urlOpenSoft={tab.urlOpenSoft} - refreshKey={isActive ? refreshKey : 0} + refreshKey={isActive && !shellModalOpen ? refreshKey : 0} onTitleChange={(title) => handleTabTitleChange(tab.id, title)} onAppsChanged={handleAppsChanged} /> diff --git a/packages/desktop-app/src/renderer/components/AppSettings.tsx b/packages/desktop-app/src/renderer/components/AppSettings.tsx index b12a77758b..c77f4172ba 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,8 @@ import { IconFolderPlus, IconAlertCircle, IconKeyboard, + IconLogout2, + IconUserCircle, } from "@tabler/icons-react"; import { useState, @@ -518,6 +520,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 +563,20 @@ export default function AppSettings({ } }, [onFrameSettingsChanged]); + useEffect(() => { + const api = window.electronAPI?.identity; + if (!api) return; + void api.getStatus().then(setIdentityStatus); + return api.onStatusChange(setIdentityStatus); + }, []); + + 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 +892,37 @@ 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. Return to the app and try again." + : "Open any first-party app and sign in there once"} + +
+
+
+ {identityStatus === "signed-in" && ( + + )} +
+
+ {providerSettings && ( ; + signOut(): Promise; + onStatusChange(cb: (status: DesktopIdentityStatus) => void): () => void; + }; + setActiveApp(appId: string): void; setActiveWebview(target: { appId: string; diff --git a/packages/desktop-app/src/renderer/shell.css b/packages/desktop-app/src/renderer/shell.css index e323b5e6e4..0623e36e02 100644 --- a/packages/desktop-app/src/renderer/shell.css +++ b/packages/desktop-app/src/renderer/shell.css @@ -536,6 +536,12 @@ body { transform: translate3d(-200vw, 0, 0); } +/* A macOS guest can eventually repaint into its old bounds while offscreen. + Shell modals must remove the native surface from layout without unmounting it. */ +.content-area--shell-modal-open .webview-slot { + display: none; +} + .content-area--code-agents .webview-slot { z-index: 0; opacity: 0; diff --git a/packages/dispatch/src/config.ts b/packages/dispatch/src/config.ts index 4fce732578..c7b5aeecd0 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 aae179b3a2..08d8412e84 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 new file mode 100644 index 0000000000..94906071cb --- /dev/null +++ b/plans/desktop-sso-intent/work-ledger.md @@ -0,0 +1,331 @@ +# Desktop workspace SSO work ledger + +```yaml +stage: work +authority-source: "Alice invoked /work on 2026-08-02 against desktop-sso-diagnosis-plan-r1, then required an explicit stop before the next production test." +authorized-scope: + repositories: + - BuilderIO/agent-native + - alicemoore/teenylilthoughts + 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 + - push + - pull-request + - deploy +write-targets: + artifacts: + - packages/desktop-app + - .agents/skills/authentication/SKILL.md + - plans/desktop-sso-intent/work-ledger.md +governing-artifact: + path: /Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary diagnosis and replacement work plan 2026-08-02.md + revision: desktop-sso-diagnosis-plan-r3 +architecture-fingerprint: + outcome: one Agent Native workspace sign-in begun from the ordinary front door of any canonical first-party app, followed by silent app-local federation + shipping-surfaces: + - 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: PR #2290 exact head plus its signed branch-canary artifact; stable Desktop release remains outside this lane + integration-action: push + - id: core-identity + repository: BuilderIO/agent-native + product-surface: public Core authentication and identity-federation runtime + constituency: source-blind Agent Native developers and app users + durable-destination: PR #2290 exact head with its Core changeset; package publication remains outside this lane + integration-action: push + - id: dispatch-authority + repository: BuilderIO/agent-native + product-surface: Dispatch identity authorize flow + constituency: first-party workspace users and self-hosted workspace operators + durable-destination: PR #2290 exact head; stable Dispatch integration remains outside this lane + integration-action: push + - id: hosted-app-fleet + repository: BuilderIO/agent-native + product-surface: short locked canonical-origin Mail and Dispatch canary deployments + constituency: supervised pre-merge canary testers + durable-destination: exact candidate and rollback receipts recorded in the ledger, with canonical production restored after the test + integration-action: deploy + governing-architecture: Electron main keeps one dedicated Dispatch identity partition, intercepts the ordinary sign-in entry of an eligible canonical app, and lets the exact nonce-bound authenticated completion navigation commit with HTTP 200 before copying only that app's allowlisted session; Settings remains status, sign-out, and recovery; local development retains its loopback file broker; custom apps and Builder Connect remain outside the boundary + acceptance-story: + id: desktop-first-party-sso-committed-completion-v3 + summary: signing in normally from any eligible canonical first-party app commits an authenticated nonce-bound completion before Desktop transfers that app's session, returns to the initiating app without exposing Dispatch home, and lets every later canonical app open as the same verified account while preserving app-local sessions, databases, authorization, and data isolation + required-assertions: + - signed packaged Desktop and real canary deployment pass before merge + - the ordinary sign-in entry in each enabled canonical first-party app, including Dispatch, can initiate the shared workspace ceremony and returns to the initiating app without using Dispatch home as a completion screen + - the Desktop Settings account card accurately reflects an existing authority session and never presents Dispatch home as successful sign-in; any signed-out action hands off to an app front door or is clearly labeled as recovery + - every enabled canonical first-party app resolves the correct existing account and data without another credential ceremony + - restart, workspace sign-out, account switch, standalone browser, custom app, and local-development behavior pass + - hostile redirect, nonce, origin, cookie, concurrency, cancellation, and logging cases fail closed + - the Settings H1 miss is deterministically classified before production, and Settings code changes only if the failure is reproduced and localized + - Agent Native identity sign-in remains separate from app-specific provider connection and consent + - Builder internal and Builder credentials do not participate + risk-strategy: + kind: system-ready + production-validation-after-merge: false +delegation-ceiling: + - read-only inventory and verification +architecture-grounding: + applicability: required + reason: authentication spans Desktop, Core, Dispatch, and every participating first-party deployment + status: grounded + demonstrated-callers: + - pre-fix Desktop Settings account Sign in invoked IPC.IDENTITY_SIGN_IN and the direct Dispatch authority ceremony + - a canonical app webview reaching its exact /_agent-native/sign-in invokes the existing app-targeted Desktop federation ceremony + existing-primitives: + - DesktopIdentityBroker.ensureAppSession uses the initiating app's existing identity/login route and copies only that app's allowlisted local session + - the pre-fix DesktopIdentityBroker.signInAuthority opened Dispatch's generic sign-in document in the dedicated identity partition + - Core's generic sign-in document redirects an already-authenticated session to its validated continuation + - Dispatch remains the existing identity authority and authorize endpoint + ownership-boundaries: + - Desktop main owns partitions, ceremony lifecycle, trusted app resolution, cookie transfer, and bounded status IPC + - Core owns the generic sign-in journey and trusted-app federation client/callback + - Dispatch owns human authentication, redirect validation, and identity assertion minting + - each target app owns its local user, session, authorization, provider grants, and data + legacy-contracts: + - preserve legacy ?return= consumption on /_agent-native/sign-in + - preserve standalone browser, direct app login, local loopback broker, custom-app exclusion, app-local databases, and per-provider consent + - preserve the dedicated identity partition and exact app/origin/callback/cookie trust boundaries + shared-vocabulary: + - workspace sign-in means Agent Native identity federation, not provider connection sharing + - app front door means the app's ordinary sign-in entry, not a hidden Dispatch account page + - Settings account card is a secondary status, sign-out, and recovery surface + smallest-compatible-delta: keep the eligible app's ordinary sign-in entry as the workspace front door, allow only its exact nonce-bound completion navigation to commit, require authenticated HTTP 200 before cookie transfer, and leave Settings unchanged unless deterministic evidence localizes its H1 failure + deferred-capabilities: + - custom and third-party app federation + - shared provider OAuth grants or automatic provider-scope expansion + - replacing Dispatch, Better Auth, or app-local sessions + reversibility: Desktop-only routing and status behavior can be reverted without schema, credential, provider, or deployment-data migration; hosted federation remains opt-in per app + direct-evidence: + - the Clip shows Settings Sign in visibly landing on Dispatch home instead of returning to an initiating app + - pre-fix source routed Settings directly to signInAuthority(dispatch) and used Dispatch root as its completion observation + - current source already intercepts exact sign-in navigation for eligible canonical apps and completes app-local federation in the dedicated identity partition + - Core documentation defines federation as happening at the app front door and preserves app-local sessions + - canary .21 completed Alice's private Google ceremony but logged a value-free Mail session-transfer failure after Desktop cancelled the announced completion redirect and polled for two seconds + - Electron navigation ordering makes did-navigate with its response code the first committed main-frame completion boundary + inferences: + - the Clip's immediate root redirect is consistent with a pre-existing Dispatch session in the identity partition, but the Clip does not expose cookie or IPC status evidence + - Dispatch self-federation is source-supported but still requires current signed-runtime acceptance + unresolved-owner-questions: [] +product-boundary-gates: + agent-native-public-constituency: source-blind developers packaging Desktop with standard Core and Dispatch apps receive the reusable identity boundary without Alice-specific infrastructure +acceptance-state: + status: pending + summary: Alice approved committed-completion v3; Work is active locally on PR #2290, while production must remain normal until Alice is notified and the task stops for the supervised canary + verified: + - Core identity protocol suite: 210 tests passed + - Desktop main, renderer, shared, broker, and preload suites: 202 tests passed + - 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 + - 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 + - current origin/main ce426feef1ebeb370fca581291c9391339a757ed integrated in merge commit 7d4cbf499; updater and Dispatch identity conflicts were resolved by retaining both current-main recovery/sign-in behavior and the frozen SSO safety boundaries + - post-integration Desktop broker and preload suite: 17 tests passed; Core identity: 18; Dispatch auth: 1; Desktop typecheck and diff checks passed + - independent security review verified authenticated server revocation, logout-all escalation during revocation and cleanup, exhaustive partial-failure cleanup, and dormant production-session cleanup for disabled, Dev-switched, missing, or edited canonical app configurations + - prior head 8d33ffffe9e8b0b3acc78e725736a96f97dae7e5 passed required CI and the signed/notarized macOS canary workflow + - the short locked production canary proved canonical Mail to Dispatch routing, callback/state construction, hostile-callback rejection, and safe reverse-order rollback + - the exact signed canary launched and reached production Dispatch, but its browser had no authenticated Dispatch identity + - pre-integration updater guard focused suite: 10 tests passed; Desktop identity, preload, and updater suite: 27 tests passed; Desktop typecheck and diff checks passed + - pre-integration independent review found no updater-code defects and verified the exact canary version family has no feed, check, download, install, listener, focus, ready-callback, or timer capability while stable and unrelated prerelease builds retain normal updater behavior + - post-integration focused verification on 7d4cbf499: Desktop identity, updater policy/runtime, and preload 29 tests passed; Core identity 27; Dispatch primary-auth forwarding 1; Dispatch identity library 24 + - post-integration conflict review verified every updater touchpoint retains the exact canary eligibility guard while current-main delayed native staging and manual-result notifications remain intact; Dispatch retains the sign-in journey while primary auth remains the sole initializer + - exact-head required CI run 30480891661 passed on 3d380f5db0d5ae93b310b3665c165d2a3716f1fd, including build, typecheck, security guards, Core integration, scaffold, SSR smoke, and every fast-test lane + - exact-head signed canary run 30480891545 passed signing, notarization, provenance, trust verification, and short-lived artifact upload for desktop-sso-canary-3d380f5db0d5ae93b310b3665c165d2a3716f1fd + - downloaded canary 0.1.150-desktop-sso-canary.9 matched all four manifest SHA-256 values and the arm64 app passed strict deep codesign, stapler, Gatekeeper, bundle id, version, signer, and architecture checks locally + - exact-head canary .9 is installed side-by-side at /Applications/Agent Native SSO Canary.app; the prior .2 canary is preserved recoverably at /Applications/Agent Native SSO Canary.previous.app and the stable Desktop app is untouched + - immutable exact-head Netlify candidates 6a6a48cbd8ca3400088ba95f (Mail) and 6a6a48cbc9c76200085e212c (Dispatch) are ready and unpublished + - candidate preflight proved Mail constructs the canonical Dispatch authorize route, Dispatch returns the logged-out sign-in continuation for the canonical Mail callback, and Dispatch rejects a hostile callback with 400 before session work + - fresh production rollback targets are Mail 6a6a3a4ee65f5f0008ca3fc5 and Dispatch 6a6a3a4e9b07310008196a34, both current-main ce426feef1ebeb370fca581291c9391339a757ed + - exact-head required CI run 30489087026 and signed canary run 30489085253 passed on 5d57687b13b0d3572bbd6a10a525ed43cba28139; canary .12 passed manifest checks, strict signing, notarization, Gatekeeper, bundle identity, version, and arm64 verification + - independent updater-isolation QA on canary .12 passed a four-minute observation and real quit/relaunch cycle with no updater activity + - two fresh post-publication profiles proved the identity control renders but native activation does not reach Dispatch; both short production windows were rolled back in reverse order and Mail and Dispatch were restored unlocked to ce426fe + - Electron 41.9 runtime probes proved session.fetch manual redirects throw Redirect was cancelled, followed fetches omit response.url, and net.request manual mode exposes the first redirect event + - the replacement native redirect resolver passes 19 focused identity/navigation tests, 228 full Desktop tests, Desktop typecheck, formatting, and diff checks; independent review found no redirect-trust, request-lifecycle, cancellation, cookie-isolation, or logging defect + - exact-head required CI run 30555246811 and signed canary run 30555243503 passed on d19c92f1d1ebe3ab336506e9f27e6efa9125da25 + - downloaded canary 0.1.150-desktop-sso-canary.13 matched all four manifest SHA-256 values and passed strict deep codesign, stapler, Gatekeeper, bundle id, version, signer, and arm64 checks locally + - independent pre-publication QA on canary .13 passed a fresh-profile launch, disabled updater observation, real quit, process-absence check, and relaunch without touching the stable Desktop app + - the short locked production canary published immutable candidates 6a6a48cbd8ca3400088ba95f (Mail) and 6a6a48cbc9c76200085e212c (Dispatch); valid canonical routes returned the expected 302 chain and hostile redirect and forged callback requests returned 400 + - fresh-profile native acceptance on canary .13 passed Mail activation into the canonical Dispatch sign-in ceremony with the exact Mail app id and callback; the run stopped safely when Dispatch presented credential fields + - the canary window rolled back in reverse order to current-main 13f7e6bceceb52f04b69201f911729c174524041 using Mail deploy 6a6b46a26b16cb0008e0cf26 and Dispatch deploy 6a6b46a2ef65c600082759f5; both sites were unlocked, temporary identity routes returned baseline 401, Mail root retained normal redirect behavior, and canary and updater processes were absent + - superseded direct Dispatch authority sign-in implementation 652fc57b68408812af748302141df1f01d6ab95c passed all 232 Desktop tests, Desktop typecheck, production build, formatting, diff checks, and a credential-material diff scan before the front-door acceptance story invalidated it + - front-door replacement removes the Settings authority sign-in command end to end across renderer, preload, IPC, main, broker, tests, and authentication guidance while retaining status and workspace sign-out + - live status refresh now rejects stale results when a sign-in ceremony or workspace sign-out begins during cookie I/O and skips authority-cookie inspection throughout an active sign-out + - Dispatch as the initiating app now accepts only its exact same-origin nonce-bound desktop completion when Core detects the existing authority session, copies only the allowlisted Dispatch app session, retains the dedicated authority cookie, and never opens Dispatch home + - replacement front-door Desktop verification: all 231 tests across 24 files passed; Desktop TypeScript, production build, formatting, and git diff checks passed + - final independent read-only review found no remaining source-level defect or requested coverage gap in the status concurrency, direct-completion, cancellation, or cookie-isolation paths + - current origin/main 3e89c5e5aea6752beea0849fbd62c5a1d58b986d was integrated without textual conflicts in merge commit 6e202d737ad3f84ba3801a38b596b118d1fa69a2; all four acceptance-relevant overlaps retain both the SSO behavior and current-main additions + - post-integration focused verification on 6e202d737: Desktop identity and updater 24 tests passed; Core identity 27; Dispatch primary auth 1; Dispatch template identity 24 + - post-integration full relevant verification on 6e202d737: Desktop 231 tests, Dispatch package 336, Dispatch template 41, Desktop/Core/Dispatch/Dispatch-template typechecks, Core and Dispatch builds, Desktop production compile, formatting hygiene, and git diff checks passed + - the broader Core package sweep passed 10080 tests and failed 228 tests because Node 24 exposed no browser localStorage in the Mac test runtime; every failing test source is unchanged from current main, so this is recorded as baseline harness evidence rather than SSO acceptance + - local artifact inspection found that packaged Desktop SSO canaries still inherited stable Desktop's Electron userData path even though development was isolated; canary .19 was therefore not installed + - the packaged canary version family now selects Agent Native SSO Canary as its userData directory before Sentry or logger initialization and aborts launch if that isolated profile cannot be established, while stable Desktop retains its existing profile + - canary-profile fix verification: all 236 Desktop tests passed; Desktop TypeScript, production build, formatting, and git diff checks passed + - the startup regression suite proves profile creation and selection precede Sentry and logger initialization, packaged isolation failure aborts before either consumer starts, stable Desktop leaves its profile unchanged, and development retains its recoverable fallback + - exact-head required CI run 30702326735 and signed canary run 30702325684 passed on 7535935d40475d57d715f323129a577d9b301838; canary .20 matched its manifest and passed strict codesign, stapler, Gatekeeper, bundle identity, version, signer, and arm64 checks + - independent native preflight on canary .20 proved its isolated userData profile, unchanged stable profile, disabled updater, and real quit/relaunch behavior; H1 passed the signed-out Settings/status boundary and H2 passed ordinary Mail front-door routing into Dispatch with app=mail + - after Alice completed the private Google step, the ceremony returned to canonical Mail but the underlying app remained on its pre-auth loading fallback; no post-completion Mail reload appeared in the redacted Desktop log, so H4 failed and H5-H10 were not started + - the failed production window was rolled back Mail first and Dispatch second to known-good main deploys 6a6ddd1e966031000859bd7e and 6a6ddd1e8eef7d00084f719e; both identity routes returned baseline 401, both sites were unlocked, all Canary/updater processes were stopped, and the stable profile mtime remained unchanged + - the completion handler can observe Mail's redirect before Chromium commits its Set-Cookie result; the broker now waits up to two seconds for only the exact allowlisted target cookie, preserves cancellation fencing, and reloads to a recoverable app state with a value-free diagnostic if transfer still fails + - cookie-commit repair verification: all 240 Desktop tests passed; Desktop TypeScript, production build, formatting, and git diff checks passed + - final independent read-only re-review confirmed the prior cancellation and hung-cookie-read findings are resolved, the new close/timeout/read-stall coverage is sufficient, and no remaining source-level correctness or credential-handling defect was found + - committed-completion repair verification: all 244 Desktop tests across 26 files passed; Desktop TypeScript and production build passed; formatting and git diff checks passed + - focused shared-seam verification: Core identity protocol and store 27 tests, Dispatch primary-auth forwarding 1 test, and Dispatch identity library 24 tests passed + - a renderer-level regression mounted the real Desktop App and Sidebar around an active slow-loading webview and proved the Settings dialog opens; the prior H1 symptom is therefore not reproduced in the renderer event path and no Settings product code changed + - Electron 41.9 runtime proof observed an exact completion navigation commit with HTTP 200 and one obvious-fake target cookie becoming available afterward; no task Electron or updater process remained + - completion regressions prove will-redirect is not cancelled, no cookie read begins before did-navigate with HTTP 200, 401 completion fails closed without reading or copying cookies, a wrong nonce is ignored, direct Dispatch completion uses the same committed boundary, and diagnostics contain app id plus status only + - independent review found and the implementation repaired a cancellation race: every new ceremony now drains tracked prior session-copy cleanup before opening, and immediate reauthentication cannot lose its new target cookie to a late cleanup; re-review found no remaining actionable source defect + - exact-head CI run 30755672926 and signed canary run 30755671927 passed on 6426bcad3d98bda7e13e774137d257886f675cab; canary .23 matched all four manifest hashes and passed strict signing, notarization, Gatekeeper, bundle identity, version, signer, and arm64 checks + - independent native canary .23 H1 passed Settings over a slow-loading Mail webview and H2 passed more than four minutes of updater isolation; stable profile mtime remained unchanged and no credential or production mutation occurred + - canary .23 H3 failed because normal Cmd+Q removed the window while the main and helper processes remained alive; root terminated only the exact Canary process tree and verified process absence, identifying the pre-existing Multi-Frontier quit guard as a signed-candidate blocker rather than entering production + - exact-head CI run 30756794521 and signed canary run 30756792323 passed on d6607b41c64451e82cf1ab6f9214759af9bfe86c; canary .24 passed manifest, signing, notarization, Gatekeeper, isolated-profile, updater-silence, normal-quit, process-absence, and relaunch checks while stable profile mtime remained unchanged + - independent canary .24 H1 proved Settings functionally opens over slow-loading Mail, but its native guest backdrop became blank gray and the modal content later blanked during H2; H3 relaunch restored normal rendering, localizing the remaining gate to macOS native-guest compositing rather than React state or quit lifecycle + - exact-head CI run 30757701197 and signed canary run 30757699380 passed on 579d5f590cb06fffd807669d2b4ea343b49b13ec; canary .25 passed manifest, signing, notarization, Gatekeeper, bundle identity, version, signer, architecture, isolated-profile, and updater-silence checks while stable profile mtime remained unchanged + - a reliable local CDP plus CGWindow harness replaced the prior accessibility lease that closed the Electron browser when detaching; canary .25 then passed initial native H1 with Settings readable and both guest slots offscreen, but its unattended four-minute CGWindow capture was blank while React still reported Settings visible, so H2 was not accepted and production remained untouched + - local compositor hardening removes every guest slot from layout with display:none only while an App-owned shell modal is open, preserves the same two WebContents target ids across close, and restores the active guest without navigation or remount; the four-minute local hold remained readable when the app was activated and independent review found no actionable lifecycle, Code Agents, fullscreen, refresh, or state-preservation defect + - exact-head CI run 30759362478 and Desktop SSO Canary run 30759360971 passed on cff2f0a0454c536ada52a045791ec6e62d6ce047; Canary .26 passed signing, notarization, isolated-profile, Settings compositor, updater-silence, normal-quit, process-absence, and relaunch gates + - the supervised .26 production attempt reached the correct canonical Dispatch ceremony from ordinary Mail, then stopped before authentication because the promoted Deploy Preview candidate rendered only email/password while Alice's workspace account is Google-only + - Deploy Preview candidate 6a6f8bdd884a450856725dfc is exact head cff2f0a0454c536ada52a045791ec6e62d6ce047 and its immutable sign-in document contains password forms but no google-btn element; its Netlify context is deploy-preview + - the earlier Dispatch candidate 6a6dfc950e84fea87e565474 that supported Alice's private Google ceremony was built in Netlify production context on the same branch, establishing deployment context as the smallest current diagnosis rather than a Desktop or cross-app protocol defect + - exact-head unpublished branch candidates 6a6f90d3d8056b4161659f28 (Dispatch) and 6a6f90fba1156f7c5ab0fa43 (Mail) were rebuilt without cache in branch-deploy context; both are ready at cff2f0a0454c536ada52a045791ec6e62d6ce047 and neither is published + - the fresh production-shaped Dispatch candidate still renders three password inputs and no google-btn, disproving build context alone as the repair; valid Mail callback authorize remains 302 and a hostile callback remains 400 + - canonical-host-only Dispatch repair passes 4 focused resolver tests, all 45 Dispatch template tests, template typecheck, formatting, diff hygiene, and credential-pattern scan + - independent read-only auth review found no actionable issue in exact-origin matching, environment precedence, local/self-hosted preservation, startup evaluation, or test coverage + - the failed window rolled back Mail then Dispatch to normal main@9258da4 deploys 6a6f49a689f67900088bb2ea and 6a6f49a6474bd80008d64fff; both sites are unlocked with auto-publishing on, identity routes baseline 401, Mail root normal 302, no Canary/updater process, and stable profile mtime unchanged + implementation: + - authenticated nonce-only app-local completion route in Core + - dedicated persistent Dispatch identity partition in packaged Desktop + - 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-out IPC without credential material + - workspace-wide Desktop sign-out preserves exact canonical POST logout and logout-all server semantics, retains request-start credentials only for the active cleanup operation, and reports partial failure truthfully + - sign-out cleanup inventories every immutable canonical packaged production partition independently of sidebar enablement, Dev mode, or edited URLs while leaving localhost and custom origins untouched + - operator docs, all localized counterparts, authentication skill, and Core changeset + - branch-scoped signed macOS canary workflow with no publishing, tags, releases, or updater feed + - Dispatch primary-auth public-route configuration eliminating concurrent auth-initializer pre-emption + - the ordinary sign-in entry in a canonical first-party app starts the app-targeted workspace ceremony; Settings exposes status and workspace sign-out but no separate authority sign-in command + - exact nonce-bound app-local completion must commit with HTTP 200 before isolated app-session transfer, followed only by bounded allowlisted-cookie synchronization and value-free diagnostics + - Desktop quit waits for Multi-Frontier cleanup but reissues application quit after synchronous failure or a five-second bound so optional collaboration disposal cannot leave a windowless process indefinitely + - every App-owned shell modal makes ordinary and Code Agents guest webviews inactive and removes their native slots from layout, then restores the same mounted guests and deferred refresh behavior without navigation or reload + blockers: + - commit and push the canonical-host-only Dispatch repair, then rebuild unpublished exact-head candidates and prove the immutable Dispatch sign-in document contains google-btn before any production publication + - a renewed short Mail and Dispatch window must prove H1-H10, including same-account continuity, restart, provider separation, workspace sign-out, and safe cancellation/concurrency; the prior H1-H2 evidence is informative but cannot complete acceptance for the repaired artifact + - Alice requires an explicit notification and agent stop before any next production test or Netlify production mutation + latest-production-result: https://github.com/BuilderIO/agent-native/pull/2290#issuecomment-5158731013 + pr-cleanup-receipt: ten archived intermediate progress comments were deleted on 2026-08-02; human discussion, all review threads, concise finding-resolution replies, and the latest production failure/rollback result remain +deployment-boundary: + allowed: + - branch-scoped GitHub Actions macOS canary build with publish disabled + - 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 +return-to-shape: + banner: resolved by Alice's explicit 2026-08-02 Work approval + invalidated-field: governing architecture and acceptance story + old-fingerprint: + outcome: one Desktop workspace identity ceremony followed by silent app-local federation + governing-architecture: dedicated Dispatch identity partition plus app-local federation, with Settings able to start a direct authority ceremony + acceptance-story: desktop-first-party-sso-v1 + risk-strategy: system-ready, production validation before merge + proposed-fingerprint: + outcome: one workspace sign-in begun at any canonical app's ordinary front door, followed by silent app-local federation + governing-architecture: dedicated Dispatch identity partition plus existing app-targeted federation; Settings is secondary status, sign-out, and recovery and never exposes Dispatch home as completion + acceptance-story: desktop-first-party-sso-front-door-v2 + risk-strategy: unchanged system-ready, production validation before merge + replacement-acceptance-story: desktop-first-party-sso-committed-completion-v3 approved +production-safety-preflight: + verified-at: 2026-08-02T14:46:39-04:00 + mail: normal unlocked auto-publishing main deploy 6a6f49a689f67900088bb2ea at 9258da46f4b0291860913b1ec05743b6330a92e2, identity route baseline 401, root normal 302 + dispatch: normal unlocked auto-publishing main deploy 6a6f49a6474bd80008d64fff at 9258da46f4b0291860913b1ec05743b6330a92e2, identity authorize baseline 401 + local: no SSO Canary, ShipIt, Squirrel, or updater process remains; stable Desktop profile mtime unchanged at 1785439008 +work-constraints: + production-test-custody: notify Alice and stop before any production canary or Netlify production mutation + pr-cleanup: remove agent-authored progress-comment clutter after preserving durable truth locally; retain human review and concise reviewer-facing information +ledger-revision: desktop-sso-work-r31 +status: active +``` + +## Return-to-shape review: the sign-in front door + +### What changed + +The original product destination was “one human sign-in for every canonical first-party app,” but the current Settings implementation added a direct authority ceremony whose visible success path is Dispatch's ordinary home page. Alice's Clip and feedback establish a more specific successful-user story: the normal sign-in inside any individual Agent Native app should be the front door to the shared workspace identity. Dispatch may remain the identity authority underneath, but Dispatch home is not a user-facing completion screen. + +### Evidence-backed diagnosis + +- The pre-fix Settings button called `signInAuthority("dispatch")`, opened `/_agent-native/sign-in?return=/` in the dedicated identity partition, and treated Dispatch `/` plus an allowlisted cookie as completion. +- Core's generic sign-in document intentionally redirects an already-authenticated session to its validated return path. The Clip's immediate jump to `/` is therefore consistent with an existing authority session, although the recording alone cannot prove the cookie or final IPC status. +- The app-led path already exists: an eligible canonical app reaching its ordinary sign-in entry is intercepted, federates through Dispatch in the dedicated identity partition, mints a normal session in the initiating app, and copies only that app's allowlisted cookie into its own partition. +- Provider connection remains a separate concern. Signing into the Agent Native account once does not silently grant Gmail, Slack, Notion, or other provider scopes. + +### Options + +1. **Patch only the Dispatch-home flash.** Preflight the authority session before opening the Settings window. This is mechanically smallest, but it leaves Settings as a privileged direct sign-in path and does not fully answer the front-door feedback. +2. **Use the app front door everywhere (recommended).** Keep the dedicated identity partition and existing federation. Make ordinary canonical-app sign-in the primary entry; have any Settings signed-out action hand off to the current eligible app's same ceremony, with a preflight so an existing authority session completes silently. Settings remains useful for status, workspace sign-out, and recovery, but never lands on Dispatch home. +3. **Reuse or synchronize the visible Dispatch app partition.** This could make Dispatch itself the shared cookie jar, but it collapses or duplicates the existing authority/app partition boundary and adds account-switch and logout coupling without evidence that the larger change is needed. + +### Recommendation + +Approve option 2. It is the smallest delta that addresses both the concrete Clip defect and the product expectation. It keeps the existing identity authority, dedicated partition, per-app local sessions, and provider-consent boundaries. Implementation should delete or stop using the Settings-only direct-root completion path, route through the existing app-targeted ceremony, add an authority-session preflight, and test Dispatch as an initiating canonical app rather than assuming it works. + +### Replacement successful-user story + +A user opens any canonical first-party app in packaged Desktop and uses that app's normal sign-in. Agent Native performs one workspace identity ceremony, returns to the initiating app without exposing Dispatch home, and silently signs subsequent canonical apps into their correct existing local accounts. Settings accurately reports the shared account and supports workspace sign-out or recovery. Standalone browsers, custom apps, local development, Builder credentials, app-local authorization, and provider-specific connection consent remain unchanged. + +## Work r34 — production-context route readiness + +- Governing artifact: `/Users/alicemoore/Developer/teenylilthoughts/briefs/Agent-Native Desktop workspace SSO canary diagnosis and replacement work plan 2026-08-02.md` +- Governing revision: `desktop-sso-diagnosis-plan-r4` +- Lifecycle state: Work active +- Authority source: Alice invoked `$work` on 2026-08-03 and required an explicit stop before the next production test. +- Starting PR head: `2506adc6771af12ada0cd1da6fd20271cd119ce2` +- Refreshed base: `origin/main@6a771571028d8e2a911eb0d1bcda607c32c555ca` +- Dedicated lane: `/Users/alicemoore/.codex/worktrees/desktop-workspace-sso/agent-native`, branch `codex/desktop-workspace-sso`, clean at start and equal to the open PR head. + +### Bounded repair + +- Hosted Dispatch now requests `googleOnly` only when the first configured public origin is exactly `https://dispatch.agent-native.com` and Netlify's deploy context is exactly `production`. +- Deploy Preview, branch-deploy, missing-context, local, self-hosted, malformed, and lookalike cases fail closed to the compatible password-capable presentation. +- Explicit `APP_URL` and `BETTER_AUTH_URL` continue to take precedence over the platform `URL`. +- The existing pending Dispatch changelog entry now states the production behavior and preview fallback without adding a duplicate entry. +- No Core route, OAuth credential, credential scope, callback, Desktop release workflow, stable updater, production deploy, or merge state changed in this slice. + +### Current local evidence + +- Focused hosted-auth resolver: 5 tests passed. +- Full Dispatch template suite: 46 tests passed across 4 files. +- Dispatch template typecheck: passed. +- `guard:no-secret-literals`: passed. +- `guard:no-env-credentials`: passed. +- Formatting and `git diff --check`: passed. +- Independent auth/deploy-context review: no actionable finding; exact context/origin fail-closed behavior and explicit-origin precedence were confirmed. A follow-up assertion was added for `BETTER_AUTH_URL` precedence. + +### Outstanding acceptance + +- Commit and push the bounded repair, then require fresh exact-head normal CI and signed Desktop Canary checks. +- Build unpublished Dispatch and Mail drafts with Netlify production context without `--prod`; prove C7-C9, including a real `/_agent-native/google/auth-url` 302 from the immutable Dispatch candidate. +- Verify the exact signed artifact and isolated Canary app/profile through the non-production C6 gates. +- Refresh current production rollback and route baselines, then notify Alice and stop before any lock or publication. +- H0-H10 remain unstarted for the repaired exact artifact. PR #2290 remains open and unmerged. + +Ledger revision: `desktop-sso-work-r34`. diff --git a/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md b/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md new file mode 100644 index 0000000000..a092aefd46 --- /dev/null +++ b/templates/dispatch/changelog/2026-08-02-google-workspace-sign-in.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-08-02 +--- + +Hosted Dispatch now offers Desktop workspace Google sign-in in production without showing an unusable Google-only entry in previews. diff --git a/templates/dispatch/server/lib/hosted-auth.spec.ts b/templates/dispatch/server/lib/hosted-auth.spec.ts new file mode 100644 index 0000000000..2a94f4ebff --- /dev/null +++ b/templates/dispatch/server/lib/hosted-auth.spec.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; + +import { isFirstPartyHostedDispatch } from "./hosted-auth"; + +describe("isFirstPartyHostedDispatch", () => { + it("enables Google-only auth for canonical production Dispatch", () => { + expect( + isFirstPartyHostedDispatch({ + CONTEXT: "production", + URL: "https://dispatch.agent-native.com", + }), + ).toBe(true); + }); + + it("keeps preview and branch deploys password-capable", () => { + expect( + isFirstPartyHostedDispatch({ + CONTEXT: "deploy-preview", + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + expect( + isFirstPartyHostedDispatch({ + CONTEXT: "branch-deploy", + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + expect( + isFirstPartyHostedDispatch({ + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + }); + + it("keeps self-hosted and local Dispatch auth unchanged", () => { + expect( + isFirstPartyHostedDispatch({ + CONTEXT: "production", + URL: "https://dispatch.example.com", + }), + ).toBe(false); + expect( + isFirstPartyHostedDispatch({ + CONTEXT: "production", + APP_URL: "http://localhost:8080", + }), + ).toBe(false); + expect(isFirstPartyHostedDispatch({})).toBe(false); + }); + + it("honors an explicit public origin before the platform site URL", () => { + expect( + isFirstPartyHostedDispatch({ + APP_URL: "https://dispatch.example.com", + CONTEXT: "production", + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + expect( + isFirstPartyHostedDispatch({ + BETTER_AUTH_URL: "https://dispatch.example.com", + CONTEXT: "production", + URL: "https://dispatch.agent-native.com", + }), + ).toBe(false); + }); + + it("rejects malformed and lookalike origins", () => { + expect( + isFirstPartyHostedDispatch({ CONTEXT: "production", URL: "not a url" }), + ).toBe(false); + expect( + isFirstPartyHostedDispatch({ + CONTEXT: "production", + URL: "https://dispatch.agent-native.com.evil.example", + }), + ).toBe(false); + }); +}); diff --git a/templates/dispatch/server/lib/hosted-auth.ts b/templates/dispatch/server/lib/hosted-auth.ts new file mode 100644 index 0000000000..f6875234c8 --- /dev/null +++ b/templates/dispatch/server/lib/hosted-auth.ts @@ -0,0 +1,23 @@ +const FIRST_PARTY_DISPATCH_ORIGIN = "https://dispatch.agent-native.com"; + +type PublicOriginEnv = Partial< + Pick +>; + +export function isFirstPartyHostedDispatch( + env: PublicOriginEnv = process.env, +): boolean { + if (env.CONTEXT?.trim() !== "production") return false; + + const configuredOrigin = [env.APP_URL, env.BETTER_AUTH_URL, env.URL].find( + (value) => value?.trim(), + ); + + if (!configuredOrigin) return false; + + try { + return new URL(configuredOrigin).origin === FIRST_PARTY_DISPATCH_ORIGIN; + } catch { + return false; + } +} diff --git a/templates/dispatch/server/plugins/identity-sso.ts b/templates/dispatch/server/plugins/identity-sso.ts index 6ef9dbf236..ed1c751e1d 100644 --- a/templates/dispatch/server/plugins/identity-sso.ts +++ b/templates/dispatch/server/plugins/identity-sso.ts @@ -50,28 +50,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 { signInJourney } from "@agent-native/core/shared"; import { defineEventHandler, getMethod } from "h3"; import type { H3Event } from "h3"; @@ -238,13 +226,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..fd831cc772 100644 --- a/templates/dispatch/server/plugins/setup-dispatch.ts +++ b/templates/dispatch/server/plugins/setup-dispatch.ts @@ -1,3 +1,13 @@ import { setupDispatch } from "@agent-native/dispatch/server"; -export default setupDispatch(); +import { isFirstPartyHostedDispatch } from "../lib/hosted-auth"; + +export default setupDispatch({ + auth: { + googleOnly: isFirstPartyHostedDispatch(), + publicPaths: [ + "/_agent-native/identity/authorize", + "/_agent-native/org/apps", + ], + }, +});