mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(composio): surface actionable re-auth CTA when triggers fail with SESSION_EXPIRED (#4296)
Co-authored-by: M3gA-Mind <megamind@mahadao.com>
This commit is contained in:
co-authored by
M3gA-Mind
parent
85c92d6399
commit
5319479182
@@ -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('<TriggerToggles>', () => {
|
||||
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(<TriggerToggles toolkitSlug="gmail" toolkitName="Gmail" connectionId="c1" />);
|
||||
|
||||
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(<TriggerToggles toolkitSlug="gmail" toolkitName="Gmail" connectionId="c1" />);
|
||||
|
||||
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 () => {
|
||||
|
||||
@@ -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<ComposioAvailableTrigger[] | null>(null);
|
||||
const [activeBySignature, setActiveBySignature] = useState<Map<string, ComposioActiveTrigger>>(
|
||||
new Map()
|
||||
);
|
||||
const [loadError, setLoadError] = useState<string | null>(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<string | null>(null);
|
||||
const [rowError, setRowError] = useState<string | null>(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 (
|
||||
<div
|
||||
className="border-t border-line-subtle pt-3 mt-1"
|
||||
data-testid="trigger-session-expired">
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-500/30 bg-amber-50 dark:bg-amber-900/10 p-3">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200 leading-relaxed">
|
||||
{t('composio.triggers.sessionExpired')}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
onClick={() => void clearSession()}>
|
||||
{t('settings.embeddings.signInAgain')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="border-t border-line-subtle pt-3 mt-1">
|
||||
<p className="text-[11px] text-coral-600">{loadError}</p>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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<unknown>({
|
||||
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<ComposioAvailableTriggersResponse>(raw);
|
||||
}
|
||||
@@ -320,7 +325,13 @@ export async function listAvailableTriggers(
|
||||
export async function listTriggers(toolkit?: string): Promise<ComposioActiveTriggersResponse> {
|
||||
const params: Record<string, unknown> = {};
|
||||
if (toolkit) params.toolkit = toolkit;
|
||||
const raw = await callCoreRpc<unknown>({ method: 'openhuman.composio_list_triggers', params });
|
||||
const raw = await callCoreRpc<unknown>({
|
||||
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<ComposioActiveTriggersResponse>(raw);
|
||||
}
|
||||
|
||||
|
||||
@@ -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': 'نقل لليسار',
|
||||
|
||||
@@ -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': 'বামে সরান',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'बाएं ले जाएं',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '왼쪽으로 이동',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Переместить влево',
|
||||
|
||||
@@ -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': '向左移动',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<T>({
|
||||
params,
|
||||
serviceManaged = false, // kept for compatibility; direct frontend RPC does not use relay-level routing.
|
||||
timeoutMs,
|
||||
suppressAuthExpiredEvent = false,
|
||||
}: CoreRpcRelayRequest): Promise<T> {
|
||||
void serviceManaged;
|
||||
|
||||
@@ -692,7 +707,7 @@ export async function callCoreRpc<T>({
|
||||
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<T>({
|
||||
});
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::<serde_json::Value>("/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]
|
||||
|
||||
Reference in New Issue
Block a user