fix(connectors): expire stale auth credentials (#2385)

* fix(connectors): expire stale auth credentials

Mark connector credentials as expired when provider reads report auth-shaped failures so Memory stops presenting stale connected apps as healthy.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(connectors): avoid expiring grants on platform 401

Only delete connector credentials for provider tool errors attributable to the current connector so Composio platform auth failures do not wipe valid grants.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
初晨 2026-05-27 22:44:10 +08:00 committed by GitHub
parent 54f225d6b3
commit 3abcb3a4d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 235 additions and 4 deletions

View file

@ -58,6 +58,35 @@ function isMissingOrExpiredComposioOAuthState(error: unknown): boolean {
&& error.message === 'Composio OAuth state is missing or expired';
}
function boundedJsonValueIncludesAuthStaleSignal(value: BoundedJsonValue | undefined): boolean {
if (value === undefined || value === null) return false;
if (typeof value === 'number') return value === 401;
if (typeof value === 'boolean') return false;
if (typeof value === 'string') {
const normalized = value.toLowerCase();
return normalized === '401'
|| /\bbad credentials\b/.test(normalized)
|| /\bunauthori[sz]ed\b/.test(normalized)
|| /\binvalid (?:access )?token\b/.test(normalized)
|| /\btoken (?:is )?expired\b/.test(normalized)
|| /\bexpired (?:access )?token\b/.test(normalized);
}
if (Array.isArray(value)) return value.some(boundedJsonValueIncludesAuthStaleSignal);
return Object.values(value).some(boundedJsonValueIncludesAuthStaleSignal);
}
function isConnectorAuthStaleError(error: unknown, request: Pick<ConnectorExecuteRequest, 'connectorId' | 'toolName'>): boolean {
if (!(error instanceof ConnectorServiceError) || error.code !== 'CONNECTOR_EXECUTION_FAILED') return false;
const details = error.details;
return details?.connectorId === request.connectorId
&& details.toolName === request.toolName
&& boundedJsonValueIncludesAuthStaleSignal(details.error);
}
function connectorAuthExpiredMessage(definition: ConnectorCatalogDefinition): string {
return `${definition.name} authorization expired. Reconnect ${definition.name}.`;
}
function hasStoredComposioConnection(credential: ConnectorCredentialRecord | undefined, providerConnectionId: string): boolean {
return credential?.credentials.provider === 'composio'
&& credential.credentials.providerConnectionId === providerConnectionId;
@ -421,6 +450,11 @@ export class ConnectorStatusService {
return cloneStatus(next);
}
markAuthenticationExpired(definition: ConnectorCatalogDefinition, lastError: string, accountLabel?: string): ConnectorConnectionStatus {
this.credentialStore?.delete(definition.id);
return this.setError(definition, lastError, accountLabel);
}
clear(connectorId: string): void {
this.statuses.delete(connectorId);
}
@ -776,7 +810,15 @@ export class ConnectorService {
this.enforceRunLimits(context);
const providerOutput = await this.executeConnectorProviderTool(request, context, definition, tool);
let providerOutput: BoundedJsonObject;
try {
providerOutput = await this.executeConnectorProviderTool(request, context, definition, tool);
} catch (error) {
if (isConnectorAuthStaleError(error, request)) {
this.statusService.markAuthenticationExpired(definition, connectorAuthExpiredMessage(definition), connector.accountLabel);
}
throw error;
}
const protectedOutput = protectConnectorOutput(providerOutput);
const output = protectedOutput.output;
const outputSummary = summarizeConnectorOutput(output);

View file

@ -74,6 +74,20 @@ class OutputTestConnectorService extends TestConnectorService {
}
}
class FailingConnectorService extends TestConnectorService {
constructor(
definition: ConnectorCatalogDefinition,
statusService: ConnectorStatusService,
private readonly error: Error,
) {
super(definition, statusService, true);
}
protected override async executeConnectorProviderTool(_request: ConnectorExecuteRequest, _context: ConnectorExecutionContext): Promise<BoundedJsonObject> {
throw this.error;
}
}
function readOnlyDefinition(): ConnectorCatalogDefinition {
return externalConnector({
tools: [{
@ -88,6 +102,33 @@ function readOnlyDefinition(): ConnectorCatalogDefinition {
});
}
function githubReadDefinition(): ConnectorCatalogDefinition {
return externalConnector({
id: 'github',
name: 'GitHub',
provider: 'composio',
category: 'Developer',
authentication: 'composio',
providerConnectorId: 'github',
tools: [{
name: 'github.search',
title: 'Search GitHub',
requiredScopes: ['repo:read'],
safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only GitHub search' },
refreshEligible: true,
}],
allowedToolNames: ['github.search'],
minimumApproval: 'auto',
});
}
function connectGithub(statusService: ConnectorStatusService): void {
statusService.connect(githubReadDefinition(), 'octocat@example.com', {
provider: 'composio',
providerConnectionId: 'ca_stale_github',
});
}
afterEach(() => {
vi.useRealTimers();
});
@ -538,6 +579,93 @@ describe('connector execution policy', () => {
)).rejects.toMatchObject({ code: 'CONNECTOR_NOT_CONNECTED' });
});
it('marks a persisted Composio connector as errored when tool execution reports stale auth', async () => {
const definition = githubReadDefinition();
const credentialStore = new InMemoryConnectorCredentialStore();
const statusService = new ConnectorStatusService({ credentialStore });
connectGithub(statusService);
const service = new FailingConnectorService(
definition,
statusService,
new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'Composio tool execution failed', 502, {
connectorId: 'github',
toolName: 'github.search',
error: {
message: 'Bad credentials',
documentation_url: 'https://docs.github.com/rest',
status: '401',
},
}),
);
await expect(service.execute(
{ connectorId: 'github', toolName: 'github.search', input: {} },
{ projectsRoot: '/tmp/open-design-test', projectId: 'project-a', purpose: 'artifact_refresh' },
)).rejects.toMatchObject({ code: 'CONNECTOR_EXECUTION_FAILED' });
await expect(service.getConnector('github')).resolves.toMatchObject({
status: 'error',
accountLabel: 'octocat@example.com',
lastError: 'GitHub authorization expired. Reconnect GitHub.',
});
expect(service.getCredential('github')).toBeUndefined();
});
it('keeps connector credentials when Composio platform auth fails before tool execution', async () => {
const definition = githubReadDefinition();
const credentialStore = new InMemoryConnectorCredentialStore();
const statusService = new ConnectorStatusService({ credentialStore });
connectGithub(statusService);
const service = new FailingConnectorService(
definition,
statusService,
new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'Composio request failed with HTTP 401', 401, {
httpStatus: 401,
}),
);
await expect(service.execute(
{ connectorId: 'github', toolName: 'github.search', input: {} },
{ projectsRoot: '/tmp/open-design-test', projectId: 'project-a', purpose: 'artifact_refresh' },
)).rejects.toMatchObject({ code: 'CONNECTOR_EXECUTION_FAILED', status: 401 });
await expect(service.getConnector('github')).resolves.toMatchObject({
status: 'connected',
accountLabel: 'octocat@example.com',
});
expect(service.getCredential('github')).toBeDefined();
});
it('keeps connector credentials when tool execution fails without auth-stale payload', async () => {
const definition = githubReadDefinition();
const credentialStore = new InMemoryConnectorCredentialStore();
const statusService = new ConnectorStatusService({ credentialStore });
connectGithub(statusService);
const service = new FailingConnectorService(
definition,
statusService,
new ConnectorServiceError('CONNECTOR_EXECUTION_FAILED', 'Composio tool execution failed', 502, {
connectorId: 'github',
toolName: 'github.search',
error: {
message: 'Not Found',
status: '404',
},
}),
);
await expect(service.execute(
{ connectorId: 'github', toolName: 'github.search', input: {} },
{ projectsRoot: '/tmp/open-design-test', projectId: 'project-a', purpose: 'artifact_refresh' },
)).rejects.toMatchObject({ code: 'CONNECTOR_EXECUTION_FAILED', status: 502 });
await expect(service.getConnector('github')).resolves.toMatchObject({
status: 'connected',
accountLabel: 'octocat@example.com',
});
expect(service.getCredential('github')).toBeDefined();
});
it('rejects non-auto connector tools during artifact refresh', async () => {
const definition = externalConnector({
tools: [{

View file

@ -1939,13 +1939,15 @@ export function MemorySection({
&& !authorizationPending
&& !connectError
&& !connecting;
const connectorLastError = connector.lastError?.trim();
const reconnecting = connector.status === 'error';
const connectorHint = connected
? connector.accountLabel || `${connector.tools.length} read tools`
: checkingStatus
? 'Checking connection status…'
: authorizationPending
? 'Finish authorization in your browser, then return here'
: connectError || 'Connect this app before extraction';
: connectorLastError || connectError || 'Connect this app before extraction';
return (
<label
key={connector.id}
@ -1983,7 +1985,7 @@ export function MemorySection({
className={`memory-connector-connect-button${connecting || authorizationPending || checkingStatus ? ' is-loading' : ''}`}
disabled={connecting || authorizationPending || checkingStatus}
aria-busy={connecting || authorizationPending || checkingStatus || undefined}
aria-label={`Connect ${connector.name}`}
aria-label={`${reconnecting ? 'Reconnect' : 'Connect'} ${connector.name}`}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@ -1996,7 +1998,7 @@ export function MemorySection({
className={connecting || authorizationPending || checkingStatus ? 'icon-spin' : ''}
/>
<span>
{checkingStatus ? 'Checking' : authorizationPending ? 'Waiting' : connecting ? 'Connecting' : 'Connect'}
{checkingStatus ? 'Checking' : authorizationPending ? 'Waiting' : connecting ? 'Connecting' : reconnecting ? 'Reconnect' : 'Connect'}
</span>
</button>
)}

View file

@ -929,6 +929,65 @@ describe('MemorySection', () => {
expect((within(remountedGithubRow).getByRole('button', { name: 'Connect GitHub' }) as HTMLButtonElement).disabled).toBe(true);
});
it('shows reconnect guidance for memory connectors with stale authorization', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;
const lastError = 'GitHub authorization expired. Reconnect GitHub.';
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = input.toString();
if (url === '/api/memory') {
return new Response(JSON.stringify({
enabled: true,
rootDir: '/tmp/memory',
index: '# Memory\n',
entries: [],
extraction: null,
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/memory/extractions') {
return new Response(JSON.stringify({ extractions: [] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
if (url === '/api/connectors/discovery?hydrateTools=false') {
return new Response(JSON.stringify({
connectors: [
{
id: 'github',
name: 'GitHub',
provider: 'composio',
category: 'Developer',
status: 'error',
lastError,
tools: [],
},
],
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
if (url === '/api/connectors/status') {
return new Response(JSON.stringify({
statuses: {
github: { status: 'error', lastError },
},
}), { status: 200, headers: { 'content-type': 'application/json' } });
}
return new Response(JSON.stringify({}), { status: 404 });
}) as typeof fetch;
renderMemorySection();
fireEvent.click(await screen.findByRole('tab', { name: 'Import from apps' }));
const githubRow = await waitFor(() => {
const row = document.querySelector('[data-memory-connector-id="github"]');
expect(row).toBeTruthy();
return row as HTMLElement;
});
expect(within(githubRow).getByText(lastError)).toBeTruthy();
expect(within(githubRow).getByRole('button', { name: 'Reconnect GitHub' })).toBeTruthy();
expect(within(githubRow).queryByText('Select')).toBeNull();
});
it('shows connector read failures instead of a generic empty state', async () => {
globalThis.EventSource = StubEventSource as unknown as typeof EventSource;