@open-mercato/ai-assistant 0.6.5-develop.4882.1.901c3aa813 → 0.6.5-develop.5033.1.c970204a3f
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js +4 -8
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js +6 -5
- package/dist/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.js.map +2 -2
- package/dist/modules/ai_assistant/lib/codemode-tools.js +17 -20
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-test-runner.js +5 -3
- package/dist/modules/ai_assistant/lib/tool-test-runner.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-LOOP-001-006.spec.ts +6 -18
- package/src/modules/ai_assistant/__integration__/TC-AI-TOKEN-USAGE-001-005.spec.ts +7 -5
- package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +128 -0
- package/src/modules/ai_assistant/lib/__tests__/tool-test-runner-pick-default-tenant.test.ts +70 -0
- package/src/modules/ai_assistant/lib/codemode-tools.ts +21 -29
- package/src/modules/ai_assistant/lib/tool-test-runner.ts +11 -6
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { McpToolContext } from '../types'
|
|
2
2
|
import { getApiEndpoints } from '../api-endpoint-index'
|
|
3
|
+
import { fetchWithTimeout } from '@open-mercato/shared/lib/http/fetchWithTimeout'
|
|
3
4
|
import {
|
|
4
5
|
authorizeCodeModeApiRequest,
|
|
6
|
+
CODE_MODE_MAX_API_CALLS,
|
|
7
|
+
CODE_MODE_MAX_MUTATION_CALLS,
|
|
5
8
|
CODE_MODE_REQUIRED_FEATURES,
|
|
9
|
+
createApiRequestFn,
|
|
10
|
+
isUnsafeHttpMethod,
|
|
6
11
|
matchApiEndpointPath,
|
|
7
12
|
} from '../codemode-tools'
|
|
8
13
|
|
|
@@ -11,7 +16,46 @@ jest.mock('../api-endpoint-index', () => ({
|
|
|
11
16
|
getRawOpenApiSpec: jest.fn(),
|
|
12
17
|
}))
|
|
13
18
|
|
|
19
|
+
jest.mock('@open-mercato/shared/lib/http/fetchWithTimeout', () => ({
|
|
20
|
+
fetchWithTimeout: jest.fn(),
|
|
21
|
+
resolveTimeoutMs: jest.fn(() => 30000),
|
|
22
|
+
}))
|
|
23
|
+
|
|
14
24
|
const mockedGetApiEndpoints = jest.mocked(getApiEndpoints)
|
|
25
|
+
const mockedFetchWithTimeout = jest.mocked(fetchWithTimeout)
|
|
26
|
+
|
|
27
|
+
function okResponse() {
|
|
28
|
+
return {
|
|
29
|
+
ok: true,
|
|
30
|
+
status: 200,
|
|
31
|
+
text: jest.fn().mockResolvedValue('{}'),
|
|
32
|
+
} as unknown as Response
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Replicate the execute() handler's per-run counters so the regression test
|
|
37
|
+
* exercises the same accounting that gates real api.request() calls.
|
|
38
|
+
*/
|
|
39
|
+
function createCountingOnCall() {
|
|
40
|
+
let apiCallCount = 0
|
|
41
|
+
let mutationCallCount = 0
|
|
42
|
+
const onCall = (normalizedMethod: string) => {
|
|
43
|
+
apiCallCount++
|
|
44
|
+
if (apiCallCount > CODE_MODE_MAX_API_CALLS) {
|
|
45
|
+
throw new Error(`API call limit exceeded (max ${CODE_MODE_MAX_API_CALLS})`)
|
|
46
|
+
}
|
|
47
|
+
if (isUnsafeHttpMethod(normalizedMethod)) {
|
|
48
|
+
mutationCallCount++
|
|
49
|
+
if (mutationCallCount > CODE_MODE_MAX_MUTATION_CALLS) {
|
|
50
|
+
throw new Error(`Mutation API call limit exceeded (max ${CODE_MODE_MAX_MUTATION_CALLS})`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
onCall,
|
|
56
|
+
counts: () => ({ apiCallCount, mutationCallCount }),
|
|
57
|
+
}
|
|
58
|
+
}
|
|
15
59
|
|
|
16
60
|
type MockRbacService = {
|
|
17
61
|
hasAllFeatures: jest.Mock<boolean, [string[], string[]]>
|
|
@@ -242,3 +286,87 @@ describe('authorizeCodeModeApiRequest', () => {
|
|
|
242
286
|
})
|
|
243
287
|
})
|
|
244
288
|
})
|
|
289
|
+
|
|
290
|
+
describe('mutation call cap (issue #2724)', () => {
|
|
291
|
+
beforeEach(() => {
|
|
292
|
+
mockedGetApiEndpoints.mockReset()
|
|
293
|
+
mockedFetchWithTimeout.mockReset()
|
|
294
|
+
mockedFetchWithTimeout.mockResolvedValue(okResponse())
|
|
295
|
+
// Documented, feature-authorized POST endpoint so RBAC always allows the call.
|
|
296
|
+
mockedGetApiEndpoints.mockResolvedValue([
|
|
297
|
+
{
|
|
298
|
+
id: 'create_company',
|
|
299
|
+
operationId: 'create_company',
|
|
300
|
+
method: 'POST',
|
|
301
|
+
path: '/api/customers/companies',
|
|
302
|
+
summary: '',
|
|
303
|
+
description: '',
|
|
304
|
+
tags: [],
|
|
305
|
+
requiredFeatures: ['customers.companies.create'],
|
|
306
|
+
parameters: [],
|
|
307
|
+
requestBodySchema: null,
|
|
308
|
+
deprecated: false,
|
|
309
|
+
},
|
|
310
|
+
])
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
function authorizedContext(): McpToolContext {
|
|
314
|
+
return createContext({
|
|
315
|
+
userFeatures: ['ai_assistant.view', 'customers.companies.create'],
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
it('counts a dynamically-built POST method against the mutation cap', async () => {
|
|
320
|
+
const { onCall, counts } = createCountingOnCall()
|
|
321
|
+
const apiRequest = createApiRequestFn(authorizedContext(), onCall)
|
|
322
|
+
|
|
323
|
+
// The method string is built at runtime — the old static regex never saw it.
|
|
324
|
+
const dynamicMethod = 'PO' + 'ST'
|
|
325
|
+
await apiRequest({ method: dynamicMethod, path: '/api/customers/companies', body: {} })
|
|
326
|
+
|
|
327
|
+
expect(counts()).toEqual({ apiCallCount: 1, mutationCallCount: 1 })
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('refuses once the dynamically-built mutation cap is exceeded', async () => {
|
|
331
|
+
const { onCall } = createCountingOnCall()
|
|
332
|
+
const apiRequest = createApiRequestFn(authorizedContext(), onCall)
|
|
333
|
+
|
|
334
|
+
const dynamicMethod = ['P', 'O', 'S', 'T'].join('')
|
|
335
|
+
for (let index = 0; index < CODE_MODE_MAX_MUTATION_CALLS; index++) {
|
|
336
|
+
await apiRequest({ method: dynamicMethod, path: '/api/customers/companies', body: {} })
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
await expect(
|
|
340
|
+
apiRequest({ method: dynamicMethod, path: '/api/customers/companies', body: {} })
|
|
341
|
+
).rejects.toThrow(`Mutation API call limit exceeded (max ${CODE_MODE_MAX_MUTATION_CALLS})`)
|
|
342
|
+
|
|
343
|
+
// Authorized mutations actually hit fetch up to the cap; the cap throws before fetch on the overflow call.
|
|
344
|
+
expect(mockedFetchWithTimeout).toHaveBeenCalledTimes(CODE_MODE_MAX_MUTATION_CALLS)
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
it('does not charge GET reads against the mutation cap', async () => {
|
|
348
|
+
mockedGetApiEndpoints.mockResolvedValue([
|
|
349
|
+
{
|
|
350
|
+
id: 'list_companies',
|
|
351
|
+
operationId: 'list_companies',
|
|
352
|
+
method: 'GET',
|
|
353
|
+
path: '/api/customers/companies',
|
|
354
|
+
summary: '',
|
|
355
|
+
description: '',
|
|
356
|
+
tags: [],
|
|
357
|
+
requiredFeatures: [],
|
|
358
|
+
parameters: [],
|
|
359
|
+
requestBodySchema: null,
|
|
360
|
+
deprecated: false,
|
|
361
|
+
},
|
|
362
|
+
])
|
|
363
|
+
const { onCall, counts } = createCountingOnCall()
|
|
364
|
+
const apiRequest = createApiRequestFn(createContext(), onCall)
|
|
365
|
+
|
|
366
|
+
for (let index = 0; index < CODE_MODE_MAX_MUTATION_CALLS + 5; index++) {
|
|
367
|
+
await apiRequest({ method: 'get', path: '/api/customers/companies' })
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
expect(counts()).toEqual({ apiCallCount: CODE_MODE_MAX_MUTATION_CALLS + 5, mutationCallCount: 0 })
|
|
371
|
+
})
|
|
372
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { AwilixContainer } from 'awilix'
|
|
2
|
+
import { pickDefaultTenant } from '../tool-test-runner'
|
|
3
|
+
|
|
4
|
+
type ExecuteCall = { sql: string; params?: unknown[] }
|
|
5
|
+
|
|
6
|
+
function makeContainer(
|
|
7
|
+
rowsBySql: (sql: string) => Record<string, unknown>[],
|
|
8
|
+
calls: ExecuteCall[],
|
|
9
|
+
): AwilixContainer {
|
|
10
|
+
const connection = {
|
|
11
|
+
execute: async (sql: string, params?: unknown[]) => {
|
|
12
|
+
calls.push({ sql, params })
|
|
13
|
+
return rowsBySql(sql)
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
const em = { getConnection: () => connection }
|
|
17
|
+
return {
|
|
18
|
+
resolve: (token: string) => {
|
|
19
|
+
if (token === 'em') return em
|
|
20
|
+
throw new Error(`[internal] unexpected resolve token: ${token}`)
|
|
21
|
+
},
|
|
22
|
+
} as unknown as AwilixContainer
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('pickDefaultTenant SQL safety (#2725)', () => {
|
|
26
|
+
it('binds the tenant id as a query parameter instead of interpolating it', async () => {
|
|
27
|
+
const calls: ExecuteCall[] = []
|
|
28
|
+
const tenantId = '11111111-2222-3333-4444-555555555555'
|
|
29
|
+
const orgId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
|
30
|
+
const userId = 'ffffffff-0000-1111-2222-333333333333'
|
|
31
|
+
const container = makeContainer((sql) => {
|
|
32
|
+
if (sql.includes('FROM tenants')) return [{ id: tenantId }]
|
|
33
|
+
if (sql.includes('FROM organizations')) return [{ id: orgId }]
|
|
34
|
+
if (sql.includes('FROM users')) return [{ id: userId }]
|
|
35
|
+
return []
|
|
36
|
+
}, calls)
|
|
37
|
+
|
|
38
|
+
const result = await pickDefaultTenant(container)
|
|
39
|
+
|
|
40
|
+
expect(result).toEqual({ tenantId, organizationId: orgId, userId })
|
|
41
|
+
|
|
42
|
+
const orgCall = calls.find((call) => call.sql.includes('FROM organizations'))
|
|
43
|
+
const userCall = calls.find((call) => call.sql.includes('FROM users'))
|
|
44
|
+
expect(orgCall).toBeDefined()
|
|
45
|
+
expect(userCall).toBeDefined()
|
|
46
|
+
|
|
47
|
+
// Tenant value flows through bound parameters, never string-interpolated.
|
|
48
|
+
expect(orgCall?.params).toEqual([tenantId])
|
|
49
|
+
expect(userCall?.params).toEqual([tenantId])
|
|
50
|
+
expect(orgCall?.sql).toContain('tenant_id = ?')
|
|
51
|
+
expect(userCall?.sql).toContain('tenant_id = ?')
|
|
52
|
+
|
|
53
|
+
// No call may embed the tenant value or hand-rolled quote-escaping in SQL.
|
|
54
|
+
for (const call of calls) {
|
|
55
|
+
expect(call.sql).not.toContain(tenantId)
|
|
56
|
+
expect(call.sql).not.toContain("''")
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('returns null when no tenant exists without issuing scoped lookups', async () => {
|
|
61
|
+
const calls: ExecuteCall[] = []
|
|
62
|
+
const container = makeContainer(() => [], calls)
|
|
63
|
+
|
|
64
|
+
const result = await pickDefaultTenant(container)
|
|
65
|
+
|
|
66
|
+
expect(result).toBeNull()
|
|
67
|
+
expect(calls).toHaveLength(1)
|
|
68
|
+
expect(calls[0]?.sql).toContain('FROM tenants')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
@@ -550,22 +550,10 @@ function buildEntitySchemas(graph: EntityGraph) {
|
|
|
550
550
|
})
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
-
/**
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
function detectMutationInCode(code: string): { hasMutation: boolean; methods: string[] } {
|
|
558
|
-
const methods: string[] = []
|
|
559
|
-
const pattern = /method:\s*['"](\w+)['"]/gi
|
|
560
|
-
let match
|
|
561
|
-
while ((match = pattern.exec(code)) !== null) {
|
|
562
|
-
const method = match[1].toUpperCase()
|
|
563
|
-
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)) {
|
|
564
|
-
methods.push(method)
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
return { hasMutation: methods.length > 0, methods }
|
|
568
|
-
}
|
|
553
|
+
/** Maximum api.request() calls allowed per execute() run, regardless of method. */
|
|
554
|
+
export const CODE_MODE_MAX_API_CALLS = 50
|
|
555
|
+
/** Maximum mutation (non-GET/HEAD/OPTIONS) api.request() calls allowed per execute() run. */
|
|
556
|
+
export const CODE_MODE_MAX_MUTATION_CALLS = 20
|
|
569
557
|
|
|
570
558
|
/**
|
|
571
559
|
* Load and register the two Code Mode tools.
|
|
@@ -701,19 +689,24 @@ RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection pat
|
|
|
701
689
|
}
|
|
702
690
|
}
|
|
703
691
|
|
|
704
|
-
//
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
console.error(`[AI Usage] execute: MUTATION DETECTED (${mutationInfo.methods.join(',')}) — capping API calls to ${maxApiCalls}`)
|
|
709
|
-
}
|
|
692
|
+
// Cap API calls for safety. The mutation cap is enforced against the
|
|
693
|
+
// actually-observed HTTP method, not a static scan of the source — so a
|
|
694
|
+
// dynamically-built method (e.g. 'PO' + 'ST') can never escape it.
|
|
695
|
+
const maxApiCalls = CODE_MODE_MAX_API_CALLS
|
|
710
696
|
let apiCallCount = 0
|
|
697
|
+
let mutationCallCount = 0
|
|
711
698
|
|
|
712
|
-
const apiRequestFn = createApiRequestFn(ctx, () => {
|
|
699
|
+
const apiRequestFn = createApiRequestFn(ctx, (normalizedMethod) => {
|
|
713
700
|
apiCallCount++
|
|
714
701
|
if (apiCallCount > maxApiCalls) {
|
|
715
702
|
throw new Error(`API call limit exceeded (max ${maxApiCalls})`)
|
|
716
703
|
}
|
|
704
|
+
if (isUnsafeHttpMethod(normalizedMethod)) {
|
|
705
|
+
mutationCallCount++
|
|
706
|
+
if (mutationCallCount > CODE_MODE_MAX_MUTATION_CALLS) {
|
|
707
|
+
throw new Error(`Mutation API call limit exceeded (max ${CODE_MODE_MAX_MUTATION_CALLS})`)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
717
710
|
})
|
|
718
711
|
|
|
719
712
|
const context = {
|
|
@@ -761,9 +754,9 @@ RULES: For FIND/LIST → GET only (1 call). For UPDATE → PUT to collection pat
|
|
|
761
754
|
/**
|
|
762
755
|
* Create the api.request() function for the execute sandbox.
|
|
763
756
|
*/
|
|
764
|
-
function createApiRequestFn(
|
|
757
|
+
export function createApiRequestFn(
|
|
765
758
|
ctx: McpToolContext,
|
|
766
|
-
onCall: () => void
|
|
759
|
+
onCall: (normalizedMethod: string) => void
|
|
767
760
|
): (params: {
|
|
768
761
|
method: string
|
|
769
762
|
path: string
|
|
@@ -777,11 +770,10 @@ function createApiRequestFn(
|
|
|
777
770
|
'http://localhost:3000'
|
|
778
771
|
|
|
779
772
|
return async (params) => {
|
|
780
|
-
onCall()
|
|
781
|
-
|
|
782
773
|
const { method, path, query, body } = params
|
|
783
774
|
const callStart = Date.now()
|
|
784
|
-
const normalizedMethod = method.toUpperCase()
|
|
775
|
+
const normalizedMethod = String(method ?? '').toUpperCase()
|
|
776
|
+
onCall(normalizedMethod)
|
|
785
777
|
const apiPath = normalizeApiRequestPath(path)
|
|
786
778
|
const authorization = await authorizeCodeModeApiRequest(ctx, normalizedMethod, apiPath)
|
|
787
779
|
|
|
@@ -995,7 +987,7 @@ function isPathParameterSegment(segment: string): boolean {
|
|
|
995
987
|
)
|
|
996
988
|
}
|
|
997
989
|
|
|
998
|
-
function isUnsafeHttpMethod(method: string): boolean {
|
|
990
|
+
export function isUnsafeHttpMethod(method: string): boolean {
|
|
999
991
|
return !['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())
|
|
1000
992
|
}
|
|
1001
993
|
|
|
@@ -212,14 +212,18 @@ async function loadGeneratedTools(): Promise<{ moduleId: string; tools: AiToolDe
|
|
|
212
212
|
return result
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
async function pickDefaultTenant(
|
|
215
|
+
export async function pickDefaultTenant(
|
|
216
216
|
container: AwilixContainer,
|
|
217
217
|
): Promise<{ tenantId: string; organizationId: string | null; userId: string | null } | null> {
|
|
218
218
|
try {
|
|
219
219
|
const em = container.resolve<{
|
|
220
|
-
getConnection: () => {
|
|
220
|
+
getConnection: () => {
|
|
221
|
+
execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>
|
|
222
|
+
}
|
|
221
223
|
}>('em') as unknown as {
|
|
222
|
-
getConnection: () => {
|
|
224
|
+
getConnection: () => {
|
|
225
|
+
execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>
|
|
226
|
+
}
|
|
223
227
|
}
|
|
224
228
|
const conn = em.getConnection()
|
|
225
229
|
const tenantRows = await conn.execute(
|
|
@@ -230,16 +234,17 @@ async function pickDefaultTenant(
|
|
|
230
234
|
? String((tenantRows[0] as Record<string, unknown>).id)
|
|
231
235
|
: null
|
|
232
236
|
if (!tenantId) return null
|
|
233
|
-
const escaped = tenantId.replace(/'/g, "''")
|
|
234
237
|
const orgRows = await conn.execute(
|
|
235
|
-
`SELECT id FROM organizations WHERE tenant_id =
|
|
238
|
+
`SELECT id FROM organizations WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
|
|
239
|
+
[tenantId],
|
|
236
240
|
)
|
|
237
241
|
const organizationId =
|
|
238
242
|
Array.isArray(orgRows) && orgRows[0]
|
|
239
243
|
? String((orgRows[0] as Record<string, unknown>).id)
|
|
240
244
|
: null
|
|
241
245
|
const userRows = await conn.execute(
|
|
242
|
-
`SELECT id FROM users WHERE tenant_id =
|
|
246
|
+
`SELECT id FROM users WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,
|
|
247
|
+
[tenantId],
|
|
243
248
|
)
|
|
244
249
|
const userId =
|
|
245
250
|
Array.isArray(userRows) && userRows[0]
|