Skip to content

Commit 3b8ea61

Browse files
committed
Fix Composio discovery and auth checks
1 parent 7769bb3 commit 3b8ea61

4 files changed

Lines changed: 163 additions & 7 deletions

File tree

sdk/src/composio.ts

Lines changed: 104 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ type ComposioExecuteResponse = {
1818
output: ToolResultOutput[]
1919
}
2020

21+
const COMPOSIO_DISCOVERY_TIMEOUT_MS = 750
22+
const COMPOSIO_DISCOVERY_SUCCESS_CACHE_MS = 5 * 60 * 1000
23+
const COMPOSIO_DISCOVERY_FAILURE_CACHE_MS = 60 * 1000
24+
const COMPOSIO_DISCOVERY_CACHE_MAX_ENTRIES = 64
25+
26+
const composioToolDefinitionsCache = new Map<
27+
string,
28+
{ expiresAt: number; tools: CustomToolDefinition[] }
29+
>()
30+
2131
function toJsonValue(value: unknown): JSONValue {
2232
try {
2333
return JSON.parse(JSON.stringify(value ?? null)) as JSONValue
@@ -26,6 +36,70 @@ function toJsonValue(value: unknown): JSONValue {
2636
}
2737
}
2838

39+
function createTimeoutSignal(
40+
parentSignal: AbortSignal | undefined,
41+
timeoutMs: number,
42+
) {
43+
const controller = new AbortController()
44+
const timeout = setTimeout(() => {
45+
controller.abort(new Error('Composio discovery timed out'))
46+
}, timeoutMs)
47+
const onAbort = () => controller.abort(parentSignal?.reason)
48+
49+
if (parentSignal?.aborted) {
50+
onAbort()
51+
} else {
52+
parentSignal?.addEventListener('abort', onAbort, { once: true })
53+
}
54+
55+
return {
56+
signal: controller.signal,
57+
cleanup: () => {
58+
clearTimeout(timeout)
59+
parentSignal?.removeEventListener('abort', onAbort)
60+
},
61+
}
62+
}
63+
64+
function getCachedComposioTools(apiKey: string): CustomToolDefinition[] | null {
65+
const cached = composioToolDefinitionsCache.get(apiKey)
66+
if (!cached) return null
67+
if (Date.now() >= cached.expiresAt) {
68+
composioToolDefinitionsCache.delete(apiKey)
69+
return null
70+
}
71+
return cached.tools
72+
}
73+
74+
function pruneComposioToolsCache(now: number) {
75+
for (const [apiKey, cached] of composioToolDefinitionsCache) {
76+
if (now >= cached.expiresAt) {
77+
composioToolDefinitionsCache.delete(apiKey)
78+
}
79+
}
80+
81+
while (
82+
composioToolDefinitionsCache.size >= COMPOSIO_DISCOVERY_CACHE_MAX_ENTRIES
83+
) {
84+
const oldest = composioToolDefinitionsCache.keys().next()
85+
if (oldest.done) break
86+
composioToolDefinitionsCache.delete(oldest.value)
87+
}
88+
}
89+
90+
function cacheComposioTools(
91+
apiKey: string,
92+
tools: CustomToolDefinition[],
93+
ttlMs: number,
94+
) {
95+
const now = Date.now()
96+
pruneComposioToolsCache(now)
97+
composioToolDefinitionsCache.set(apiKey, {
98+
tools,
99+
expiresAt: now + ttlMs,
100+
})
101+
}
102+
29103
async function readErrorMessage(response: Response): Promise<string> {
30104
try {
31105
const body = (await response.json()) as {
@@ -90,24 +164,41 @@ async function executeComposioToolViaServer(params: {
90164
export async function getComposioCustomToolDefinitions(params: {
91165
apiKey: string
92166
logger?: Pick<Logger, 'warn'>
167+
signal?: AbortSignal
93168
}): Promise<CustomToolDefinition[]> {
169+
const cachedTools = getCachedComposioTools(params.apiKey)
170+
if (cachedTools) return cachedTools
171+
if (params.signal?.aborted) return []
172+
173+
const discoverySignal = createTimeoutSignal(
174+
params.signal,
175+
COMPOSIO_DISCOVERY_TIMEOUT_MS,
176+
)
177+
94178
let response: Response
95179
try {
96180
response = await fetch(new URL('/api/v1/composio/tools', WEBSITE_URL), {
97181
method: 'POST',
98182
headers: {
99183
Authorization: `Bearer ${params.apiKey}`,
100184
},
185+
signal: discoverySignal.signal,
101186
})
102187
} catch (error) {
103-
params.logger?.warn(
104-
{ error: error instanceof Error ? error.message : String(error) },
105-
'Failed to fetch Composio tools',
106-
)
188+
if (!params.signal?.aborted) {
189+
cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS)
190+
params.logger?.warn(
191+
{ error: error instanceof Error ? error.message : String(error) },
192+
'Failed to fetch Composio tools',
193+
)
194+
}
107195
return []
196+
} finally {
197+
discoverySignal.cleanup()
108198
}
109199

110200
if (!response.ok) {
201+
cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS)
111202
if (response.status !== 503) {
112203
params.logger?.warn(
113204
{ status: response.status, error: await readErrorMessage(response) },
@@ -119,13 +210,13 @@ export async function getComposioCustomToolDefinitions(params: {
119210

120211
try {
121212
const body = (await response.json()) as ComposioToolsResponse
122-
return body.tools.map((tool) => ({
213+
const tools = body.tools.map((tool) => ({
123214
toolName: tool.toolName,
124215
inputSchema: tool.inputSchema,
125216
description: tool.description,
126217
endsAgentStep: true,
127218
exampleInputs: [],
128-
execute: async (input) => {
219+
execute: async (input: unknown) => {
129220
return executeComposioToolViaServer({
130221
apiKey: params.apiKey,
131222
sessionId: body.sessionId,
@@ -137,7 +228,14 @@ export async function getComposioCustomToolDefinitions(params: {
137228
})
138229
},
139230
}))
231+
cacheComposioTools(
232+
params.apiKey,
233+
tools,
234+
COMPOSIO_DISCOVERY_SUCCESS_CACHE_MS,
235+
)
236+
return tools
140237
} catch (error) {
238+
cacheComposioTools(params.apiKey, [], COMPOSIO_DISCOVERY_FAILURE_CACHE_MS)
141239
params.logger?.warn(
142240
{ error: error instanceof Error ? error.message : String(error) },
143241
'Failed to parse Composio tools response',

sdk/src/run.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,9 +516,14 @@ async function runOnce({
516516
}
517517
const userId = userInfo.id
518518

519+
if (signal?.aborted) {
520+
return getCancelledRunState('Run cancelled by user.')
521+
}
522+
519523
const composioCustomToolDefinitions = await getComposioCustomToolDefinitions({
520524
apiKey,
521525
logger,
526+
signal,
522527
})
523528

524529
for (const toolName of COMPOSIO_META_TOOL_NAMES) {

web/src/app/api/v1/composio/__tests__/composio.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,20 @@ describe('/api/v1/composio', () => {
4141
}
4242
loggerWithContext = mock(() => logger)
4343
getUserInfoFromApiKey = mock(async ({ apiKey }) => {
44+
if (apiKey === 'banned-key') {
45+
return {
46+
id: 'banned-user',
47+
email: 'banned@example.com',
48+
discord_id: null,
49+
banned: true,
50+
} as Awaited<ReturnType<GetUserInfoFromApiKeyFn>>
51+
}
4452
if (apiKey !== 'valid-key') return null
4553
return {
4654
id: 'user-123',
4755
email: 'user@example.com',
4856
discord_id: null,
57+
banned: false,
4958
} as Awaited<ReturnType<GetUserInfoFromApiKeyFn>>
5059
})
5160
})
@@ -291,4 +300,33 @@ describe('/api/v1/composio', () => {
291300
error: 'Missing or invalid Authorization header',
292301
})
293302
})
303+
304+
test('rejects suspended users before rate limiting or tool lookup', async () => {
305+
const getToolsForUser = mock(async () => ({
306+
sessionId: 'session-123',
307+
tools: [],
308+
}))
309+
const checkRateLimit = mock(() => ({ limited: false as const }))
310+
const req = new NextRequest('http://localhost/api/v1/composio/tools', {
311+
method: 'POST',
312+
headers: { Authorization: 'Bearer banned-key' },
313+
})
314+
315+
const response = await postComposioTools({
316+
req,
317+
getUserInfoFromApiKey,
318+
db: mockDb,
319+
logger,
320+
loggerWithContext,
321+
getToolsForUser,
322+
checkRateLimit,
323+
})
324+
325+
expect(response.status).toBe(403)
326+
const body = await response.json()
327+
expect(body.error).toBe('account_suspended')
328+
expect(body.message).toContain('Your account has been suspended')
329+
expect(getToolsForUser).not.toHaveBeenCalled()
330+
expect(checkRateLimit).not.toHaveBeenCalled()
331+
})
294332
})

web/src/app/api/v1/composio/_auth.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextResponse } from 'next/server'
2+
import { env } from '@codebuff/internal/env'
23

34
import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database'
45
import type {
@@ -13,6 +14,7 @@ type ComposioUser = {
1314
id: string
1415
email: string
1516
discord_id: string | null
17+
banned: boolean
1618
}
1719

1820
export async function requireComposioUser(params: {
@@ -39,7 +41,7 @@ export async function requireComposioUser(params: {
3941

4042
const userInfo = await getUserInfoFromApiKey({
4143
apiKey,
42-
fields: ['id', 'email', 'discord_id'],
44+
fields: ['id', 'email', 'discord_id', 'banned'],
4345
logger,
4446
})
4547
if (!userInfo) {
@@ -52,6 +54,19 @@ export async function requireComposioUser(params: {
5254
}
5355
}
5456

57+
if (userInfo.banned) {
58+
return {
59+
ok: false,
60+
response: NextResponse.json(
61+
{
62+
error: 'account_suspended',
63+
message: `Your account has been suspended. Please contact ${env.NEXT_PUBLIC_SUPPORT_EMAIL} if you did not expect this.`,
64+
},
65+
{ status: 403 },
66+
),
67+
}
68+
}
69+
5570
return {
5671
ok: true,
5772
userInfo,

0 commit comments

Comments
 (0)