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:
YellowSnnowmann
2026-06-30 22:09:36 +05:30
committed by GitHub
co-authored by M3gA-Mind
parent 85c92d6399
commit 5319479182
22 changed files with 363 additions and 6 deletions
@@ -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>
+4
View File
@@ -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,
});
});
+13 -2
View File
@@ -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);
}
+2
View File
@@ -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': 'نقل لليسار',
+2
View File
@@ -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': 'বামে সরান',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'बाएं ले जाएं',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': '왼쪽으로 이동',
+2
View File
@@ -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',
+2
View File
@@ -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',
+2
View File
@@ -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': 'Переместить влево',
+1
View File
@@ -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({
+17 -2
View File
@@ -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);
}
+46 -2
View File
@@ -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.
+103
View File
@@ -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]