From 5319479182d9ef4b9cfb0af0d44b83b514d0eeda Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:09:36 +0530 Subject: [PATCH] fix(composio): surface actionable re-auth CTA when triggers fail with SESSION_EXPIRED (#4296) Co-authored-by: M3gA-Mind --- .../composio/TriggerToggles.test.tsx | 48 ++++++++ .../components/composio/TriggerToggles.tsx | 43 ++++++++ app/src/lib/composio/composioApi.test.ts | 4 + app/src/lib/composio/composioApi.ts | 15 ++- app/src/lib/i18n/ar.ts | 2 + app/src/lib/i18n/bn.ts | 2 + app/src/lib/i18n/de.ts | 2 + app/src/lib/i18n/en.ts | 2 + app/src/lib/i18n/es.ts | 2 + app/src/lib/i18n/fr.ts | 2 + app/src/lib/i18n/hi.ts | 2 + app/src/lib/i18n/id.ts | 2 + app/src/lib/i18n/it.ts | 2 + app/src/lib/i18n/ko.ts | 2 + app/src/lib/i18n/pl.ts | 2 + app/src/lib/i18n/pt.ts | 2 + app/src/lib/i18n/ru.ts | 2 + app/src/lib/i18n/zh-CN.ts | 1 + .../services/__tests__/coreRpcClient.test.ts | 62 +++++++++++ app/src/services/coreRpcClient.ts | 19 +++- src/openhuman/integrations/client.rs | 48 +++++++- src/openhuman/integrations/client_tests.rs | 103 ++++++++++++++++++ 22 files changed, 363 insertions(+), 6 deletions(-) diff --git a/app/src/components/composio/TriggerToggles.test.tsx b/app/src/components/composio/TriggerToggles.test.tsx index dfbe89147..f72957a85 100644 --- a/app/src/components/composio/TriggerToggles.test.tsx +++ b/app/src/components/composio/TriggerToggles.test.tsx @@ -1,12 +1,14 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { CoreRpcError } from '../../services/coreRpcClient'; import TriggerToggles, { activeTriggerSignature, triggerSignature } from './TriggerToggles'; const mockListAvailable = vi.fn(); const mockListTriggers = vi.fn(); const mockEnable = vi.fn(); const mockDisable = vi.fn(); +const mockClearSession = vi.fn(); vi.mock('../../lib/composio/composioApi', () => ({ listAvailableTriggers: (toolkit: string, conn?: string) => mockListAvailable(toolkit, conn), @@ -16,11 +18,16 @@ vi.mock('../../lib/composio/composioApi', () => ({ disableTrigger: (id: string) => mockDisable(id), })); +vi.mock('../../providers/CoreStateProvider', () => ({ + useCoreState: () => ({ clearSession: mockClearSession }), +})); + beforeEach(() => { mockListAvailable.mockReset(); mockListTriggers.mockReset(); mockEnable.mockReset(); mockDisable.mockReset(); + mockClearSession.mockReset(); }); describe('triggerSignature / activeTriggerSignature', () => { @@ -97,6 +104,47 @@ describe('', () => { await waitFor(() => expect(screen.getByText(/Couldn't load triggers: boom/)).toBeInTheDocument() ); + // A generic (non-session) failure stays a plain message — no re-auth CTA. + expect(screen.queryByRole('button', { name: 'Sign in again' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('trigger-session-expired')).not.toBeInTheDocument(); + }); + + it('does not show the re-auth CTA for a downstream provider 401 (provider_auth)', async () => { + // A Composio-side 401 classifies as `provider_auth`, not `auth_expired`, + // so it must NOT trigger the session-expired CTA (#4281, AC#4). + mockListAvailable.mockRejectedValue( + new CoreRpcError('Composio v3 API error: HTTP 401: Unauthorized', 'provider_auth') + ); + mockListTriggers.mockResolvedValue({ triggers: [] }); + + render(); + + await waitFor(() => expect(screen.getByText(/Couldn't load triggers:/)).toBeInTheDocument()); + expect(screen.queryByRole('button', { name: 'Sign in again' })).not.toBeInTheDocument(); + expect(screen.queryByTestId('trigger-session-expired')).not.toBeInTheDocument(); + }); + + it('shows an actionable re-auth CTA when the session expired', async () => { + mockListAvailable.mockRejectedValue( + new CoreRpcError( + 'SESSION_EXPIRED: backend rejected session token on GET /agent-integrations/composio/triggers/available — sign in again to resume', + 'auth_expired' + ) + ); + mockListTriggers.mockResolvedValue({ triggers: [] }); + + render(); + + await waitFor(() => expect(screen.getByTestId('trigger-session-expired')).toBeInTheDocument()); + // The raw SESSION_EXPIRED blob is not surfaced; a clean message is. + expect(screen.queryByText(/SESSION_EXPIRED/)).not.toBeInTheDocument(); + expect( + screen.getByText('Your OpenHuman session expired. Sign in again to load triggers.') + ).toBeInTheDocument(); + + const button = screen.getByRole('button', { name: 'Sign in again' }); + fireEvent.click(button); + expect(mockClearSession).toHaveBeenCalledTimes(1); }); it('marks a trigger as enabled when present in active list (matched by signature)', async () => { diff --git a/app/src/components/composio/TriggerToggles.tsx b/app/src/components/composio/TriggerToggles.tsx index ae90e351c..15a7fafe7 100644 --- a/app/src/components/composio/TriggerToggles.tsx +++ b/app/src/components/composio/TriggerToggles.tsx @@ -9,6 +9,9 @@ import { import { formatTriggerLabel } from '../../lib/composio/formatters'; import type { ComposioActiveTrigger, ComposioAvailableTrigger } from '../../lib/composio/types'; import { useT } from '../../lib/i18n/I18nContext'; +import { useCoreState } from '../../providers/CoreStateProvider'; +import { CoreRpcError } from '../../services/coreRpcClient'; +import Button from '../ui/Button'; /** * Stable signature for matching an `AvailableTrigger` to an @@ -49,11 +52,17 @@ export default function TriggerToggles({ connectionId, }: TriggerTogglesProps) { const { t } = useT(); + const { clearSession } = useCoreState(); const [available, setAvailable] = useState(null); const [activeBySignature, setActiveBySignature] = useState>( new Map() ); const [loadError, setLoadError] = useState(null); + // Set when the load failure is a confirmed OpenHuman session expiry (the + // backend rejected the app-session JWT with a 401 → `SESSION_EXPIRED`). + // Distinct from a generic load error so we surface an actionable re-auth + // CTA instead of the raw `SESSION_EXPIRED: …` blob (#4281). + const [sessionExpired, setSessionExpired] = useState(false); const [pendingSignature, setPendingSignature] = useState(null); const [rowError, setRowError] = useState(null); @@ -63,6 +72,7 @@ export default function TriggerToggles({ setAvailable(null); setActiveBySignature(new Map()); setLoadError(null); + setSessionExpired(false); void (async () => { try { const [avail, active] = await Promise.all([ @@ -80,6 +90,11 @@ export default function TriggerToggles({ } catch (err) { if (cancelled) return; const msg = err instanceof Error ? err.message : String(err); + // `auth_expired` is the typed classification of an OpenHuman + // session-JWT 401 (`SESSION_EXPIRED`). It deliberately excludes + // downstream provider 401s (`provider_auth`) so a Composio-side + // auth failure never shows the "sign in again" CTA (#4281, AC#4). + setSessionExpired(err instanceof CoreRpcError && err.kind === 'auth_expired'); setLoadError(`${t('composio.triggers.loadError')}: ${msg}`); } })(); @@ -143,6 +158,34 @@ export default function TriggerToggles({ ); if (loadError) { + // Session expired → swap the raw `SESSION_EXPIRED: …` blob for an + // actionable banner whose CTA re-runs the sign-in flow. `clearSession` + // tears down the stale JWT and flips `isAuthenticated` false, which + // routes the user to the Welcome / sign-in screen (mirrors the + // EmbeddingsPanel managed-session pattern). #4281, AC#3. + if (sessionExpired) { + return ( +
+
+
+

+ {t('composio.triggers.sessionExpired')} +

+ +
+
+
+ ); + } return (

{loadError}

diff --git a/app/src/lib/composio/composioApi.test.ts b/app/src/lib/composio/composioApi.test.ts index b9a5ce079..632cb0a94 100644 --- a/app/src/lib/composio/composioApi.test.ts +++ b/app/src/lib/composio/composioApi.test.ts @@ -34,6 +34,7 @@ describe('composioApi trigger wrappers', () => { expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.composio_list_available_triggers', params: { toolkit: 'gmail', connection_id: 'conn_1' }, + suppressAuthExpiredEvent: true, }); expect(out.triggers).toHaveLength(1); expect(out.triggers[0].scope).toBe('static'); @@ -45,6 +46,7 @@ describe('composioApi trigger wrappers', () => { expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.composio_list_available_triggers', params: { toolkit: 'gmail' }, + suppressAuthExpiredEvent: true, }); }); @@ -54,6 +56,7 @@ describe('composioApi trigger wrappers', () => { expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.composio_list_triggers', params: {}, + suppressAuthExpiredEvent: true, }); }); @@ -63,6 +66,7 @@ describe('composioApi trigger wrappers', () => { expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.composio_list_triggers', params: { toolkit: 'gmail' }, + suppressAuthExpiredEvent: true, }); }); diff --git a/app/src/lib/composio/composioApi.ts b/app/src/lib/composio/composioApi.ts index 9137bd0e2..fc0b9bb3f 100644 --- a/app/src/lib/composio/composioApi.ts +++ b/app/src/lib/composio/composioApi.ts @@ -160,7 +160,7 @@ export async function authorize( /** * Delete an existing Composio connection. Backend verifies ownership - * before forwarding to Composio. + * before forwarding to Composio */ export async function deleteConnection( connectionId: string, @@ -310,6 +310,11 @@ export async function listAvailableTriggers( const raw = await callCoreRpc({ method: 'openhuman.composio_list_available_triggers', params, + // A 401 here is a single trigger-catalog read failure, not whole-session + // death (the connection itself is still active). Suppress the global + // sign-out so the trigger panel can show an in-place "Sign in again" CTA + // instead of being torn down (#4281, #2286). + suppressAuthExpiredEvent: true, }); return unwrapCliEnvelope(raw); } @@ -320,7 +325,13 @@ export async function listAvailableTriggers( export async function listTriggers(toolkit?: string): Promise { const params: Record = {}; if (toolkit) params.toolkit = toolkit; - const raw = await callCoreRpc({ method: 'openhuman.composio_list_triggers', params }); + const raw = await callCoreRpc({ + method: 'openhuman.composio_list_triggers', + params, + // Loaded alongside the available-triggers catalog in the same panel — + // keep its 401 handling local too (see `listAvailableTriggers`). + suppressAuthExpiredEvent: true, + }); return unwrapCliEnvelope(raw); } diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 63a1402dd..7f850db83 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -3084,6 +3084,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'المشغّلات', 'composio.triggers.listenFrom': 'الاستماع للأحداث من', 'composio.triggers.loadError': 'تعذّر تحميل المشغّلات', + 'composio.triggers.sessionExpired': + 'انتهت صلاحية جلسة OpenHuman. سجّل الدخول مرة أخرى لتحميل المشغّلات.', 'composio.triggers.needsConfiguration': 'يحتاج إلى إعداد', 'composio.triggers.noneAvailable': 'لا توجد مشغّلات متاحة حاليًا لـ', 'conversations.taskKanban.moveLeft': 'نقل لليسار', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index e030d1d1b..557e86974 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3151,6 +3151,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'ট্রিগার', 'composio.triggers.listenFrom': 'ইভেন্টের জন্য শুনুন', 'composio.triggers.loadError': 'ট্রিগার লোড করা যায়নি', + 'composio.triggers.sessionExpired': + 'আপনার OpenHuman সেশনের মেয়াদ শেষ হয়েছে। ট্রিগার লোড করতে আবার সাইন ইন করুন।', 'composio.triggers.needsConfiguration': 'কনফিগারেশন প্রয়োজন', 'composio.triggers.noneAvailable': 'বর্তমানে কোনো ট্রিগার উপলব্ধ নেই', 'conversations.taskKanban.moveLeft': 'বামে সরান', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index a30bf7a6c..fa572e995 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3226,6 +3226,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'Auslöser', 'composio.triggers.listenFrom': 'Auf Ereignisse hören von', 'composio.triggers.loadError': 'Trigger konnten nicht geladen werden', + 'composio.triggers.sessionExpired': + 'Deine OpenHuman-Sitzung ist abgelaufen. Melde dich erneut an, um Trigger zu laden.', 'composio.triggers.needsConfiguration': 'Muss konfiguriert werden', 'composio.triggers.noneAvailable': 'Derzeit sind keine Auslöser verfügbar für', 'conversations.taskKanban.moveLeft': 'Bewege dich nach links', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index dfd023fca..205971076 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3688,6 +3688,8 @@ const en: TranslationMap = { 'composio.triggers.heading': 'Triggers', 'composio.triggers.listenFrom': 'Listen for events from', 'composio.triggers.loadError': "Couldn't load triggers", + 'composio.triggers.sessionExpired': + 'Your OpenHuman session expired. Sign in again to load triggers.', 'composio.triggers.needsConfiguration': 'Needs configuration', 'composio.triggers.noneAvailable': 'No triggers are currently available for', 'conversations.taskKanban.moveLeft': 'Move left', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 24b6619dd..5b74a42f6 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3203,6 +3203,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'Disparadores', 'composio.triggers.listenFrom': 'Escuchar eventos de', 'composio.triggers.loadError': 'No se pudieron cargar los triggers', + 'composio.triggers.sessionExpired': + 'Tu sesión de OpenHuman ha caducado. Vuelve a iniciar sesión para cargar los triggers.', 'composio.triggers.needsConfiguration': 'Necesita configuración', 'composio.triggers.noneAvailable': 'Actualmente no hay triggers disponibles para', 'conversations.taskKanban.moveLeft': 'Mover a la izquierda', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 8dda734a6..3c9e34f1e 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3220,6 +3220,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'Déclencheurs', 'composio.triggers.listenFrom': 'Écouter les événements de', 'composio.triggers.loadError': 'Impossible de charger les déclencheurs', + 'composio.triggers.sessionExpired': + 'Votre session OpenHuman a expiré. Reconnectez-vous pour charger les déclencheurs.', 'composio.triggers.needsConfiguration': 'Configuration requise', 'composio.triggers.noneAvailable': "Aucun déclencheur n'est actuellement disponible pour", 'conversations.taskKanban.moveLeft': 'Déplacer à gauche', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index fe331dbe4..3b3757144 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3152,6 +3152,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'ट्रिगर', 'composio.triggers.listenFrom': 'से इवेंट्स सुनें', 'composio.triggers.loadError': 'ट्रिगर्स लोड नहीं हो सके', + 'composio.triggers.sessionExpired': + 'आपका OpenHuman सत्र समाप्त हो गया है। ट्रिगर्स लोड करने के लिए फिर से साइन इन करें।', 'composio.triggers.needsConfiguration': 'कॉन्फिगरेशन ज़रूरी है', 'composio.triggers.noneAvailable': 'वर्तमान में कोई ट्रिगर उपलब्ध नहीं है', 'conversations.taskKanban.moveLeft': 'बाएं ले जाएं', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 9aecf6c3b..6187f98e9 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3158,6 +3158,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'Pemicu', 'composio.triggers.listenFrom': 'Dengarkan event dari', 'composio.triggers.loadError': 'Tidak bisa memuat trigger', + 'composio.triggers.sessionExpired': + 'Sesi OpenHuman Anda telah berakhir. Masuk lagi untuk memuat trigger.', 'composio.triggers.needsConfiguration': 'Perlu konfigurasi', 'composio.triggers.noneAvailable': 'Tidak ada trigger yang tersedia saat ini untuk', 'conversations.taskKanban.moveLeft': 'Pindah ke kiri', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 7ec8203e6..2b61615ac 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3199,6 +3199,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'Trigger', 'composio.triggers.listenFrom': 'Ascolta eventi da', 'composio.triggers.loadError': 'Impossibile caricare i trigger', + 'composio.triggers.sessionExpired': + 'La tua sessione OpenHuman è scaduta. Accedi di nuovo per caricare i trigger.', 'composio.triggers.needsConfiguration': 'Richiede configurazione', 'composio.triggers.noneAvailable': 'Nessun trigger attualmente disponibile per', 'conversations.taskKanban.moveLeft': 'Sposta a sinistra', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 029dcde2a..0cb3b706e 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3122,6 +3122,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': '트리거', 'composio.triggers.listenFrom': '다음에서 이벤트 수신:', 'composio.triggers.loadError': '트리거를 불러올 수 없습니다', + 'composio.triggers.sessionExpired': + 'OpenHuman 세션이 만료되었습니다. 트리거를 불러오려면 다시 로그인하세요.', 'composio.triggers.needsConfiguration': '구성이 필요합니다', 'composio.triggers.noneAvailable': '현재 사용할 수 있는 트리거가 없습니다:', 'conversations.taskKanban.moveLeft': '왼쪽으로 이동', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c680e23f2..e4aaf36b1 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3183,6 +3183,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'Wyzwalacze', 'composio.triggers.listenFrom': 'Nasłuchuj zdarzeń z', 'composio.triggers.loadError': 'Nie udało się wczytać wyzwalaczy', + 'composio.triggers.sessionExpired': + 'Twoja sesja OpenHuman wygasła. Zaloguj się ponownie, aby wczytać wyzwalacze.', 'composio.triggers.needsConfiguration': 'Wymaga konfiguracji', 'composio.triggers.noneAvailable': 'Brak dostępnych wyzwalaczy dla', 'conversations.taskKanban.moveLeft': 'Przesuń w lewo', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index d6d4271dc..5c67c9f52 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3203,6 +3203,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'Gatilhos', 'composio.triggers.listenFrom': 'Ouvir eventos de', 'composio.triggers.loadError': 'Não foi possível carregar os gatilhos', + 'composio.triggers.sessionExpired': + 'Sua sessão do OpenHuman expirou. Entre novamente para carregar os gatilhos.', 'composio.triggers.needsConfiguration': 'Precisa de configuração', 'composio.triggers.noneAvailable': 'Nenhum gatilho disponível no momento para', 'conversations.taskKanban.moveLeft': 'Mover para esquerda', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index bcf9d3adf..e46f59de3 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3175,6 +3175,8 @@ const messages: TranslationMap = { 'composio.triggers.heading': 'Триггеры', 'composio.triggers.listenFrom': 'Слушать события от', 'composio.triggers.loadError': 'Не удалось загрузить триггеры', + 'composio.triggers.sessionExpired': + 'Срок действия сессии OpenHuman истёк. Войдите снова, чтобы загрузить триггеры.', 'composio.triggers.needsConfiguration': 'Требуется настройка', 'composio.triggers.noneAvailable': 'Сейчас нет доступных триггеров для', 'conversations.taskKanban.moveLeft': 'Переместить влево', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 8055dcfb5..a3812d248 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2994,6 +2994,7 @@ const messages: TranslationMap = { 'composio.triggers.heading': '触发器', 'composio.triggers.listenFrom': '监听来自以下的事件', 'composio.triggers.loadError': '无法加载触发器', + 'composio.triggers.sessionExpired': '你的 OpenHuman 会话已过期。请重新登录以加载触发器。', 'composio.triggers.needsConfiguration': '需要配置', 'composio.triggers.noneAvailable': '当前没有可用的触发器:', 'conversations.taskKanban.moveLeft': '向左移动', diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index f075b009f..f0a46d647 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -146,6 +146,68 @@ describe('coreRpcClient', () => { await expect(callCoreRpc({ method: 'openhuman.config_get' })).rejects.toThrow('boom from core'); }); + test('broadcasts core-rpc-auth-expired on a SESSION_EXPIRED error by default', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: 4, + error: { + code: -32000, + message: + 'SESSION_EXPIRED: backend rejected session token on GET /agent-integrations/composio/triggers/available — sign in again to resume', + }, + }), + } as Response); + + const listener = vi.fn(); + window.addEventListener('core-rpc-auth-expired', listener); + try { + await expect(callCoreRpc({ method: 'openhuman.team_get' })).rejects.toThrow( + 'SESSION_EXPIRED' + ); + } finally { + window.removeEventListener('core-rpc-auth-expired', listener); + } + expect(listener).toHaveBeenCalledTimes(1); + }); + + test('suppressAuthExpiredEvent skips the global sign-out broadcast but still throws auth_expired', async () => { + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: async () => ({ + jsonrpc: '2.0', + id: 5, + error: { + code: -32000, + message: + 'SESSION_EXPIRED: backend rejected session token on GET /agent-integrations/composio/triggers/available — sign in again to resume', + }, + }), + } as Response); + + const listener = vi.fn(); + window.addEventListener('core-rpc-auth-expired', listener); + let caught: unknown; + try { + await callCoreRpc({ + method: 'openhuman.composio_list_available_triggers', + suppressAuthExpiredEvent: true, + }); + } catch (err) { + caught = err; + } finally { + window.removeEventListener('core-rpc-auth-expired', listener); + } + // The error still surfaces (so the panel can render its in-place CTA)… + expect(caught).toBeInstanceOf(CoreRpcError); + expect((caught as CoreRpcError).kind).toBe('auth_expired'); + // …but the global teardown event is NOT broadcast. + expect(listener).not.toHaveBeenCalled(); + }); + test('throws on non-ok HTTP response', async () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValueOnce({ diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index e2ededbde..e0db63bd3 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -26,6 +26,20 @@ interface CoreRpcRelayRequest { * [MIN, MAX] window as the global default. */ timeoutMs?: number; + /** + * When `true`, an `auth_expired` classification does NOT broadcast the + * global `core-rpc-auth-expired` event (which drives `CoreStateProvider` to + * `clearSession()` → sign the user out → unmount the current screen). The + * `CoreRpcError` is still thrown with `kind: 'auth_expired'` so the caller + * can surface a *local*, recoverable re-auth affordance instead. + * + * Use ONLY for narrow, non-authoritative reads where a single 401 must not + * be treated as whole-session death — e.g. the Composio trigger catalog + * fetches, where the connection itself is still active and the panel shows + * an in-place "Sign in again" CTA (#4281, #2286). Genuine session death is + * still caught by the authoritative paths (app snapshot, connections). + */ + suppressAuthExpiredEvent?: boolean; } /** Mirror of `parseCoreRpcTimeoutMs` bounds in `utils/config.ts`. */ @@ -598,6 +612,7 @@ export async function callCoreRpc({ params, serviceManaged = false, // kept for compatibility; direct frontend RPC does not use relay-level routing. timeoutMs, + suppressAuthExpiredEvent = false, }: CoreRpcRelayRequest): Promise { void serviceManaged; @@ -692,7 +707,7 @@ export async function callCoreRpc({ const text = await response.text(); const httpMessage = `Core RPC HTTP ${response.status}: ${text || response.statusText}`; const kind = classifyRpcError(text || response.statusText, response.status); - if (kind === 'auth_expired') + if (kind === 'auth_expired' && !suppressAuthExpiredEvent) dispatchAuthExpired( payload.method, classifyAuthExpiredReason(text || response.statusText, response.status) @@ -710,7 +725,7 @@ export async function callCoreRpc({ }); const rawMessage = json.error.message || 'Core RPC returned an error'; const kind = classifyRpcError(rawMessage, undefined, json.error.data); - if (kind === 'auth_expired') + if (kind === 'auth_expired' && !suppressAuthExpiredEvent) dispatchAuthExpired(payload.method, classifyAuthExpiredReason(rawMessage, undefined)); throw new CoreRpcError(rawMessage, kind, undefined, json.error.data); } diff --git a/src/openhuman/integrations/client.rs b/src/openhuman/integrations/client.rs index e04ed4b5c..8281860f6 100644 --- a/src/openhuman/integrations/client.rs +++ b/src/openhuman/integrations/client.rs @@ -98,14 +98,17 @@ fn handle_session_jwt_unauthorized(method: &str, path: &str, url: &str, detail: (401 for {url}: {detail}) — sign in again to resume" ); + let soft = is_composio_soft_auth_path(method, path); + tracing::warn!( path = %path, method = %method, - "[integrations] backend rejected session JWT (401) — publishing SessionExpired to drive re-login" + soft_auth = soft, + "[integrations] backend rejected session JWT (401)" ); // Demote from Sentry (SESSION_EXPIRED classifies as expected) — keeps the - // noise suppression the prior fix established. + // noise suppression the prior fix established. Applies to both paths. crate::core::observability::report_error_or_expected( message.as_str(), "integrations", @@ -117,6 +120,16 @@ fn handle_session_jwt_unauthorized(method: &str, path: &str, url: &str, detail: ], ); + // Soft path: surface the sentinel to the caller (→ in-place CTA) WITHOUT + // the global sign-out. See `is_composio_soft_auth_path`. + if soft { + tracing::debug!( + path = %path, + "[integrations] soft composio auth path — returning SESSION_EXPIRED to the panel without publishing global SessionExpired (#4281)" + ); + return message; + } + // Drive recovery: publish SessionExpired so the credentials subscriber // clears the stale token and the UI prompts re-sign-in. The reason string // is already free of secrets (it names the path + sanitized backend @@ -130,6 +143,37 @@ fn handle_session_jwt_unauthorized(method: &str, path: &str, url: &str, detail: message } +/// Composio **trigger-catalog reads** (`GET /agent-integrations/composio/triggers…`) +/// where a 401 is a single recoverable read failure rather than whole-session +/// death. The connection itself is still active — `list_connections` uses the +/// *same* session JWT and succeeds, so signing the user out on a triggers-only +/// 401 over-reacts and (per #2286) must not happen. +/// +/// For these reads [`handle_session_jwt_unauthorized`] still builds the +/// `SESSION_EXPIRED:` sentinel (so the trigger panel can classify the error and +/// render an in-place "Sign in again" CTA) and still demotes from Sentry, but +/// it does **not** publish [`DomainEvent::SessionExpired`] — that global +/// teardown would unmount the panel before the CTA is usable (#4281). A +/// genuinely dead session is still caught by the authoritative paths +/// (app-state snapshot, connections poll), which keep driving re-login. +/// +/// Scoped to `GET` deliberately: trigger **writes** (`POST` enable / disable / +/// create) keep the standard global-sign-out on a 401 — they are not the +/// "catalog won't load" surface this issue addresses, and a write that 401s on +/// a dead session has no in-place CTA to fall back to (its error renders as a +/// per-row toggle failure, not the panel banner). +fn is_composio_soft_auth_path(method: &str, path: &str) -> bool { + // Match on a real path boundary, not a bare prefix: `…/triggers` exact, + // `…/triggers/…` (the `available` catalog), or `…/triggers?…` (the active + // list with a `toolkit` query). A bare `starts_with` would also match an + // unrelated `…/triggersXYZ` route and wrongly suppress the global sign-out. + const BASE: &str = "/agent-integrations/composio/triggers"; + method.eq_ignore_ascii_case("GET") + && path + .strip_prefix(BASE) + .is_some_and(|rest| rest.is_empty() || rest.starts_with('/') || rest.starts_with('?')) +} + /// Strip any inference-style path that snuck into a backend URL before /// it becomes the [`IntegrationClient::backend_url`] field. Idempotent — /// returns the input unchanged when already clean. diff --git a/src/openhuman/integrations/client_tests.rs b/src/openhuman/integrations/client_tests.rs index 6a96522ab..ce096a645 100644 --- a/src/openhuman/integrations/client_tests.rs +++ b/src/openhuman/integrations/client_tests.rs @@ -554,6 +554,109 @@ async fn non_401_4xx_does_not_classify_as_session_expired() { ); } +// ── Composio "soft" auth path (#4281) ───────────────────────────── + +/// The trigger-catalog reads are the only routes treated as "soft" (sentinel +/// surfaced for an in-place CTA, no global sign-out). Every other backend route +/// stays authoritative — a 401 there drives the full re-login. +#[test] +fn composio_soft_auth_path_covers_only_trigger_reads() { + use super::is_composio_soft_auth_path; + // Soft: GET available catalog (with and without query), GET active-triggers list. + assert!(is_composio_soft_auth_path( + "GET", + "/agent-integrations/composio/triggers/available" + )); + assert!(is_composio_soft_auth_path( + "GET", + "/agent-integrations/composio/triggers/available?toolkit=gmail&connectionId=ca_x" + )); + assert!(is_composio_soft_auth_path( + "GET", + "/agent-integrations/composio/triggers" + )); + // GET active-triggers list with a `toolkit` query (the `?` boundary). + assert!(is_composio_soft_auth_path( + "GET", + "/agent-integrations/composio/triggers?toolkit=gmail" + )); + // Path-boundary guard: a bare prefix must NOT match an unrelated route + // that merely begins with "triggers" (CodeRabbit catch). + assert!(!is_composio_soft_auth_path( + "GET", + "/agent-integrations/composio/triggersXYZ" + )); + // Authoritative — a 401 here must still log the user out: + // trigger WRITES (enable/disable/create POST to the same path)… + assert!(!is_composio_soft_auth_path( + "POST", + "/agent-integrations/composio/triggers" + )); + // …and every non-trigger backend route. + assert!(!is_composio_soft_auth_path( + "GET", + "/agent-integrations/composio/connections" + )); + assert!(!is_composio_soft_auth_path( + "GET", + "/agent-integrations/composio/toolkits" + )); + assert!(!is_composio_soft_auth_path( + "POST", + "/agent-integrations/composio/execute" + )); + assert!(!is_composio_soft_auth_path( + "POST", + "/agent-integrations/parallel/search" + )); +} + +/// A 401 on the trigger-catalog read still carries the `SESSION_EXPIRED` +/// sentinel — the trigger panel classifies it (`CoreRpcError.kind === +/// 'auth_expired'`) and renders the in-place "Sign in again" CTA. The +/// difference from the authoritative arm (no global `SessionExpired` publish) +/// is asserted via the path gate above; here we lock the wire contract the UI +/// depends on so a future refactor can't silently drop the sentinel and leave +/// the panel with no actionable error (#4281). +#[tokio::test] +async fn get_401_composio_triggers_keeps_sentinel_for_in_place_cta() { + use crate::core::observability::{ + expected_error_kind, is_session_expired_message, ExpectedErrorKind, + }; + + let app = Router::new().route( + "/agent-integrations/composio/triggers/available", + get(|| async { + ( + StatusCode::UNAUTHORIZED, + Json(json!({ "success": false, "error": "Invalid token" })), + ) + .into_response() + }), + ); + let base = start_mock_backend(app).await; + let client = client_for(base); + let err = client + .get::("/agent-integrations/composio/triggers/available?toolkit=gmail") + .await + .expect_err("401 must surface as Err"); + let msg = format!("{err:#}"); + + assert!( + msg.contains("SESSION_EXPIRED"), + "triggers 401 must still carry the sentinel so the panel shows the CTA; got: {msg}" + ); + assert_eq!( + expected_error_kind(&msg), + Some(ExpectedErrorKind::SessionExpired), + "triggers 401 must still classify as SessionExpired (stays demoted from Sentry); got: {msg}" + ); + assert!( + is_session_expired_message(&msg), + "triggers 401 must stay recognisable as session expiry for the frontend classifier; got: {msg}" + ); +} + // ── Unit: `sanitize_backend_url` (issue #2075) ──────────────────── #[test]