import { describe, expect, it, vi } from 'vitest'; import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import path from 'node:path'; import { tmpdir } from 'node:os'; import { configureComposioConfigStore, deleteComposioAuthConfigId, readComposioConfig, readPublicComposioConfig, setComposioAuthConfigId, writeComposioConfig, } from '../src/connectors/composio-config.js'; import { composioConnectorProvider, getStaticComposioCatalogDefinitions } from '../src/connectors/composio.js'; import type { ConnectorCatalogDefinition } from '../src/connectors/catalog.js'; async function useTempComposioStore(): Promise { const dir = await mkdtemp(path.join(tmpdir(), 'od-composio-config-')); configureComposioConfigStore(dir); composioConnectorProvider.clearDiscoveryCache(); return dir; } function composioJson(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' }, }); } function composioDefinition(id = 'github'): ConnectorCatalogDefinition { return { id, name: id, provider: 'composio', category: 'code', authentication: 'composio', tools: [], allowedToolNames: [], }; } describe('composio config', () => { it('stores Composio settings in the configured data directory', async () => { const dir = await useTempComposioStore(); const publicConfig = writeComposioConfig({ apiKey: 'cmp_secret_1234', }); expect(publicConfig).toEqual({ configured: true, apiKeyTail: '1234', }); expect(readComposioConfig()).toMatchObject({ apiKey: 'cmp_secret_1234', authConfigIds: {} }); await expect(readFile(path.join(dir, 'connectors', 'composio-config.json'), 'utf8')).resolves.toContain('cmp_secret_1234'); }); it('preserves and updates persisted auth config ids', async () => { await useTempComposioStore(); writeComposioConfig({ apiKey: 'stored_secret', authConfigIds: { github: 'ac_github' } }); writeComposioConfig({}); setComposioAuthConfigId('slack', 'ac_slack'); deleteComposioAuthConfigId('github'); expect(readComposioConfig()).toEqual({ apiKey: 'stored_secret', authConfigIds: { slack: 'ac_slack' }, }); }); it('does not read Composio credentials from environment variables', async () => { await useTempComposioStore(); const originalApiKey = process.env.COMPOSIO_API_KEY; try { process.env.COMPOSIO_API_KEY = 'env_secret'; expect(readPublicComposioConfig()).toMatchObject({ configured: false, apiKeyTail: '' }); expect(composioConnectorProvider.isConfigured(composioDefinition())).toBe(false); writeComposioConfig({ apiKey: 'stored_secret' }); expect(readPublicComposioConfig()).toMatchObject({ configured: true, apiKeyTail: 'cret' }); } finally { if (originalApiKey === undefined) delete process.env.COMPOSIO_API_KEY; else process.env.COMPOSIO_API_KEY = originalApiKey; } }); it('can clear the stored API key through settings', async () => { await useTempComposioStore(); writeComposioConfig({ apiKey: 'stored_secret' }); const publicConfig = writeComposioConfig({ apiKey: '' }); expect(publicConfig.configured).toBe(false); expect(composioConnectorProvider.isConfigured(composioDefinition())).toBe(false); expect(readComposioConfig()).toEqual({ apiKey: '', authConfigIds: {} }); }); it('clears stored auth config ids when the API key changes', async () => { await useTempComposioStore(); writeComposioConfig({ apiKey: 'stored_secret', authConfigIds: { github: 'ac_github' } }); const publicConfig = writeComposioConfig({ apiKey: 'new_secret' }); expect(publicConfig).toEqual({ configured: true, apiKeyTail: 'cret' }); expect(readComposioConfig()).toEqual({ apiKey: 'new_secret', authConfigIds: {} }); }); it('ignores stale unsupported persisted technical fields', async () => { await useTempComposioStore(); writeComposioConfig({ apiKey: 'stored_secret' }); const publicConfig = writeComposioConfig({ apiKey: '', baseUrl: '', userId: '', timeoutMs: null }); expect(publicConfig).toEqual({ configured: false, apiKeyTail: '' }); expect(readComposioConfig()).toEqual({ apiKey: '', authConfigIds: {} }); }); it('loads persisted Composio catalog cache into fast definitions', async () => { const dir = await useTempComposioStore(); await mkdir(path.join(dir, 'connectors'), { recursive: true }); await writeFile(path.join(dir, 'connectors', 'composio-catalog-cache.json'), JSON.stringify({ schemaVersion: 1, provider: 'composio', fetchedAt: '2026-05-07T00:00:00.000Z', definitions: [ { id: 'slack', name: 'Slack', provider: 'composio', category: 'Communication', providerConnectorId: 'SLACK', authentication: 'composio', toolCount: 48, tools: [ { name: 'slack.slack_list_channels', title: 'List channels', description: 'List Slack channels', safety: { sideEffect: 'read', approval: 'auto', reason: 'read-only' }, refreshEligible: true, curation: { useCases: ['personal_daily_digest'], reason: 'Digest source' }, requiredScopes: ['read'], providerToolId: 'SLACK_LIST_CHANNELS', }, ], allowedToolNames: ['slack.slack_list_channels'], minimumApproval: 'auto', }, ], }, null, 2)); composioConnectorProvider.configureCatalogCache(dir); expect(composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'slack')).toMatchObject({ id: 'slack', toolCount: 48, tools: [expect.objectContaining({ name: 'slack.slack_list_channels', curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }), })], }); }); it('falls back to the static catalog when the persisted cache is empty', async () => { const dir = await useTempComposioStore(); await mkdir(path.join(dir, 'connectors'), { recursive: true }); await writeFile(path.join(dir, 'connectors', 'composio-catalog-cache.json'), JSON.stringify({ schemaVersion: 1, provider: 'composio', fetchedAt: '2026-05-07T00:00:00.000Z', definitions: [], }, null, 2)); composioConnectorProvider.configureCatalogCache(dir); expect(composioConnectorProvider.getFastDefinitions()).toEqual(getStaticComposioCatalogDefinitions()); }); it('does not hydrate persisted catalog cache before the runtime data directory is configured', async () => { const defaultCacheDir = path.join(process.cwd(), '.od', 'connectors'); const defaultCachePath = path.join(defaultCacheDir, 'composio-catalog-cache.json'); const dir = await useTempComposioStore(); await mkdir(defaultCacheDir, { recursive: true }); await writeFile(defaultCachePath, JSON.stringify({ schemaVersion: 1, provider: 'composio', fetchedAt: '2026-05-07T00:00:00.000Z', definitions: [composioDefinition('wrong-tenant')], }, null, 2)); try { vi.resetModules(); const composioModule = await import('../src/connectors/composio.js'); expect(composioModule.composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'wrong-tenant')).toBeUndefined(); await mkdir(path.join(dir, 'connectors'), { recursive: true }); await writeFile(path.join(dir, 'connectors', 'composio-catalog-cache.json'), JSON.stringify({ schemaVersion: 1, provider: 'composio', fetchedAt: '2026-05-07T00:00:00.000Z', definitions: [composioDefinition('right-tenant')], }, null, 2)); composioModule.composioConnectorProvider.configureCatalogCache(dir); expect(composioModule.composioConnectorProvider.getFastDefinitions().find((definition) => definition.id === 'right-tenant')).toMatchObject({ id: 'right-tenant', }); } finally { await rm(defaultCachePath, { force: true }); } }); it('treats the current Notion search action as read-only despite broad response-size wording', async () => { await useTempComposioStore(); writeComposioConfig({ apiKey: 'cmp_test' }); const originalFetch = globalThis.fetch; globalThis.fetch = vi.fn(async (input: Parameters[0]) => { const parsed = new URL(input.toString(), 'https://backend.composio.dev'); if (parsed.pathname === '/api/v3/auth_configs') { return composioJson({ items: [ { id: 'ac_notion', status: 'ENABLED', toolkit: { slug: 'notion' } }, ], }); } if (parsed.pathname === '/api/v3.1/toolkits') { return composioJson({ items: [ { slug: 'notion', name: 'Notion', categories: [{ name: 'Productivity' }] }, ], }); } if ( parsed.pathname === '/api/v3.1/tools' && parsed.searchParams.get('toolkit_slug') === 'notion' ) { return composioJson({ items: [ { slug: 'NOTION_SEARCH_NOTION_PAGE', name: 'Search Notion pages and databases', description: 'Searches Notion pages and databases by title. Database pages can create large responses for databases with many properties.', toolkit: { slug: 'notion' }, input_parameters: { type: 'object', properties: { query: { type: 'string' }, page_size: { type: 'integer', minimum: 1, maximum: 100 }, }, additionalProperties: false, }, tags: [], }, ], total_items: 1, }); } return composioJson({ items: [] }); }) as typeof fetch; try { const definition = await composioConnectorProvider.getPreviewDefinition('notion', { toolsLimit: 1000, }); const tool = definition?.tools.find( (candidate) => candidate.name === 'notion.notion_search_notion_page', ); expect(definition?.allowedToolNames).toContain('notion.notion_search_notion_page'); expect(tool).toMatchObject({ refreshEligible: true, safety: { sideEffect: 'read', approval: 'auto' }, curation: expect.objectContaining({ useCases: ['personal_daily_digest'] }), }); } finally { globalThis.fetch = originalFetch; } }); });