mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
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:
parent
54f225d6b3
commit
3abcb3a4d2
4 changed files with 235 additions and 4 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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: [{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue