@open-mercato/ai-assistant 0.6.5-develop.5382.1.f542de69af → 0.6.5
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +12 -0
- package/dist/modules/ai_assistant/cli.js +14 -6
- package/dist/modules/ai_assistant/cli.js.map +2 -2
- package/dist/modules/ai_assistant/data/entities.js +4 -0
- package/dist/modules/ai_assistant/data/entities.js.map +2 -2
- package/dist/modules/ai_assistant/lib/attachment-parts.js +10 -3
- package/dist/modules/ai_assistant/lib/attachment-parts.js.map +2 -2
- package/dist/modules/ai_assistant/lib/client-factory.js +0 -8
- package/dist/modules/ai_assistant/lib/client-factory.js.map +2 -2
- package/dist/modules/ai_assistant/lib/codemode-tools.js +26 -0
- package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js +23 -3
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-client.js +2 -4
- package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js +71 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js.map +7 -0
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js +1 -30
- package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
- package/dist/modules/ai_assistant/lib/mcp-server.js +29 -23
- package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
- package/dist/modules/ai_assistant/setup.js +9 -2
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/package.json +11 -12
- package/src/modules/ai_assistant/__tests__/cli-mcp-serve-key.test.ts +63 -0
- package/src/modules/ai_assistant/__tests__/setup-schedules.test.ts +33 -0
- package/src/modules/ai_assistant/cli.ts +18 -5
- package/src/modules/ai_assistant/data/__tests__/schema-unique-indexes.test.ts +39 -0
- package/src/modules/ai_assistant/data/entities.ts +5 -0
- package/src/modules/ai_assistant/lib/__tests__/attachment-parts.test.ts +112 -0
- package/src/modules/ai_assistant/lib/__tests__/client-factory.test.ts +42 -0
- package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +107 -0
- package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +30 -3
- package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +20 -1
- package/src/modules/ai_assistant/lib/__tests__/mcp-dev-key-resolution.test.ts +92 -0
- package/src/modules/ai_assistant/lib/__tests__/mcp-server-auth-fail-closed.test.ts +133 -0
- package/src/modules/ai_assistant/lib/attachment-parts.ts +26 -5
- package/src/modules/ai_assistant/lib/client-factory.ts +3 -9
- package/src/modules/ai_assistant/lib/codemode-tools.ts +49 -0
- package/src/modules/ai_assistant/lib/generated-registry-loader.ts +35 -3
- package/src/modules/ai_assistant/lib/mcp-client.ts +9 -5
- package/src/modules/ai_assistant/lib/mcp-dev-key-resolution.ts +107 -0
- package/src/modules/ai_assistant/lib/mcp-dev-server.ts +1 -36
- package/src/modules/ai_assistant/lib/mcp-server.ts +47 -33
- package/src/modules/ai_assistant/lib/types.ts +7 -0
- package/src/modules/ai_assistant/setup.ts +11 -2
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ModuleCli } from '@open-mercato/shared/modules/registry'
|
|
2
|
+
import cliCommands from '../cli'
|
|
3
|
+
|
|
4
|
+
const mockRunMcpServer = jest.fn(async () => undefined)
|
|
5
|
+
|
|
6
|
+
jest.mock('@open-mercato/shared/lib/di/container', () => ({
|
|
7
|
+
createRequestContainer: jest.fn(async () => ({})),
|
|
8
|
+
getDiRegistrars: jest.fn(() => []),
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
jest.mock('../lib/mcp-server', () => ({
|
|
12
|
+
runMcpServer: (...args: unknown[]) => mockRunMcpServer(...args),
|
|
13
|
+
}))
|
|
14
|
+
|
|
15
|
+
function getMcpServe(): ModuleCli {
|
|
16
|
+
const cmd = (cliCommands as ModuleCli[]).find((c) => c.command === 'mcp:serve')
|
|
17
|
+
if (!cmd) throw new Error('mcp:serve command not found')
|
|
18
|
+
return cmd
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('mcp:serve API key resolution (issue #2669)', () => {
|
|
22
|
+
const originalEnv = process.env.OPEN_MERCATO_API_KEY
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
jest.clearAllMocks()
|
|
26
|
+
delete process.env.OPEN_MERCATO_API_KEY
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (originalEnv === undefined) delete process.env.OPEN_MERCATO_API_KEY
|
|
31
|
+
else process.env.OPEN_MERCATO_API_KEY = originalEnv
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('reads the key from OPEN_MERCATO_API_KEY when --api-key is absent', async () => {
|
|
35
|
+
process.env.OPEN_MERCATO_API_KEY = 'omk_env.secret'
|
|
36
|
+
|
|
37
|
+
await getMcpServe().run([])
|
|
38
|
+
|
|
39
|
+
expect(mockRunMcpServer).toHaveBeenCalledTimes(1)
|
|
40
|
+
expect(mockRunMcpServer).toHaveBeenCalledWith(
|
|
41
|
+
expect.objectContaining({ apiKeySecret: 'omk_env.secret' }),
|
|
42
|
+
)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('still accepts the --api-key flag and prefers it over the env var', async () => {
|
|
46
|
+
process.env.OPEN_MERCATO_API_KEY = 'omk_env.secret'
|
|
47
|
+
|
|
48
|
+
await getMcpServe().run(['--api-key', 'omk_flag.secret'])
|
|
49
|
+
|
|
50
|
+
expect(mockRunMcpServer).toHaveBeenCalledWith(
|
|
51
|
+
expect.objectContaining({ apiKeySecret: 'omk_flag.secret' }),
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('prints usage and does not start the server when neither key nor tenant is provided', async () => {
|
|
56
|
+
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
57
|
+
|
|
58
|
+
await getMcpServe().run([])
|
|
59
|
+
|
|
60
|
+
expect(mockRunMcpServer).not.toHaveBeenCalled()
|
|
61
|
+
errorSpy.mockRestore()
|
|
62
|
+
})
|
|
63
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { setup } from '../setup'
|
|
2
|
+
|
|
3
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
4
|
+
|
|
5
|
+
function makeContainer() {
|
|
6
|
+
const register = jest.fn().mockResolvedValue(undefined)
|
|
7
|
+
return {
|
|
8
|
+
register,
|
|
9
|
+
container: {
|
|
10
|
+
resolve(name: string) {
|
|
11
|
+
if (name !== 'schedulerService') throw new Error(`unexpected registration: ${name}`)
|
|
12
|
+
return { register }
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('ai_assistant setup schedules', () => {
|
|
19
|
+
it('registers module schedules with uuid ids accepted by the scheduler table', async () => {
|
|
20
|
+
const { container, register } = makeContainer()
|
|
21
|
+
|
|
22
|
+
await setup.seedDefaults?.({ container } as never)
|
|
23
|
+
|
|
24
|
+
expect(register).toHaveBeenCalledTimes(2)
|
|
25
|
+
const ids = register.mock.calls.map(([registration]) => registration.id)
|
|
26
|
+
|
|
27
|
+
expect(ids).toHaveLength(2)
|
|
28
|
+
expect(new Set(ids).size).toBe(2)
|
|
29
|
+
expect(ids).not.toContain('ai_assistant:pending-action-cleanup')
|
|
30
|
+
expect(ids).not.toContain('ai_assistant:token-usage-prune')
|
|
31
|
+
expect(ids.every((id) => UUID_RE.test(id))).toBe(true)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -47,30 +47,42 @@ const mcpServe: ModuleCli = {
|
|
|
47
47
|
command: 'mcp:serve',
|
|
48
48
|
async run(rest) {
|
|
49
49
|
const args = parseArgs(rest)
|
|
50
|
-
|
|
50
|
+
// Prefer the OPEN_MERCATO_API_KEY env var so the secret never has to be
|
|
51
|
+
// placed on the command line (argv is world-readable via ps / /proc).
|
|
52
|
+
// The --api-key flag stays supported for backward compatibility.
|
|
53
|
+
const apiKey = String(args['api-key'] ?? args.apiKey ?? '') || process.env.OPEN_MERCATO_API_KEY || null
|
|
51
54
|
const tenantId = String(args.tenant ?? args.tenantId ?? '') || null
|
|
52
55
|
const organizationId = String(args.org ?? args.organizationId ?? '') || null
|
|
53
56
|
const userId = String(args.user ?? args.userId ?? '') || null
|
|
54
57
|
const debug = args.debug === true || args.debug === 'true'
|
|
58
|
+
const allowUnauthenticatedSuperadmin =
|
|
59
|
+
args['allow-unauthenticated-superadmin'] === true ||
|
|
60
|
+
args['allow-unauthenticated-superadmin'] === 'true'
|
|
55
61
|
|
|
56
62
|
// Either API key or tenant is required
|
|
57
63
|
if (!apiKey && !tenantId) {
|
|
58
64
|
console.error('Usage: mercato ai_assistant mcp:serve [options]')
|
|
59
65
|
console.error('')
|
|
60
66
|
console.error('Authentication (choose one):')
|
|
61
|
-
console.error('
|
|
62
|
-
console.error(' --
|
|
67
|
+
console.error(' OPEN_MERCATO_API_KEY env API key secret (recommended — keeps the secret off argv)')
|
|
68
|
+
console.error(' --api-key <secret> API key secret for authentication (visible in process listings)')
|
|
69
|
+
console.error(' --tenant <id> Tenant ID (for manual context)')
|
|
63
70
|
console.error('')
|
|
64
71
|
console.error('Options (with --tenant):')
|
|
65
72
|
console.error(' --org <id> Organization ID (optional)')
|
|
66
|
-
console.error(' --user <id> User ID for ACL (
|
|
73
|
+
console.error(' --user <id> User ID for ACL (required unless --allow-unauthenticated-superadmin is set)')
|
|
67
74
|
console.error('')
|
|
68
75
|
console.error('Common options:')
|
|
69
76
|
console.error(' --debug Enable debug logging')
|
|
77
|
+
console.error(' --allow-unauthenticated-superadmin')
|
|
78
|
+
console.error(' DEV/TEST ONLY: run as superadmin with no per-user ACL when')
|
|
79
|
+
console.error(' no --user (and no --api-key) is supplied. Never use in production.')
|
|
70
80
|
console.error('')
|
|
71
81
|
console.error('Examples:')
|
|
82
|
+
console.error(' OPEN_MERCATO_API_KEY=omk_xxxx.yyyy... mercato ai_assistant mcp:serve')
|
|
72
83
|
console.error(' mercato ai_assistant mcp:serve --api-key omk_xxxx.yyyy...')
|
|
73
|
-
console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000')
|
|
84
|
+
console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000 --user <user-id>')
|
|
85
|
+
console.error(' mercato ai_assistant mcp:serve --tenant 123e4567-e89b-12d3-a456-426614174000 --allow-unauthenticated-superadmin')
|
|
74
86
|
return
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -102,6 +114,7 @@ const mcpServe: ModuleCli = {
|
|
|
102
114
|
organizationId,
|
|
103
115
|
userId,
|
|
104
116
|
},
|
|
117
|
+
allowUnauthenticatedSuperadmin,
|
|
105
118
|
})
|
|
106
119
|
}
|
|
107
120
|
},
|
|
@@ -38,6 +38,45 @@ describe('AI assistant nullable organization unique indexes', () => {
|
|
|
38
38
|
},
|
|
39
39
|
)
|
|
40
40
|
|
|
41
|
+
it('declares every snapshot expression index on the participants entity (no snapshot/entity drift)', () => {
|
|
42
|
+
const snapshot = JSON.parse(
|
|
43
|
+
readFileSync(
|
|
44
|
+
new URL('../../migrations/.snapshot-open-mercato.json', import.meta.url),
|
|
45
|
+
'utf8',
|
|
46
|
+
),
|
|
47
|
+
) as {
|
|
48
|
+
tables: Array<{ name: string; indexes?: Array<{ keyName: string; expression?: string }> }>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const participants = snapshot.tables.find(
|
|
52
|
+
(table) => table.name === 'ai_chat_conversation_participants',
|
|
53
|
+
)
|
|
54
|
+
expect(participants).toBeDefined()
|
|
55
|
+
|
|
56
|
+
const entitySource = readFileSync(
|
|
57
|
+
new URL('../entities.ts', import.meta.url),
|
|
58
|
+
'utf8',
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
const expressionIndexes = (participants?.indexes ?? []).filter((index) => index.expression)
|
|
62
|
+
expect(expressionIndexes.length).toBeGreaterThan(0)
|
|
63
|
+
|
|
64
|
+
for (const index of expressionIndexes) {
|
|
65
|
+
expect(entitySource).toContain(index.keyName)
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('keeps the active-participants partial index declared on the entity', () => {
|
|
70
|
+
const entitySource = readFileSync(
|
|
71
|
+
new URL('../entities.ts', import.meta.url),
|
|
72
|
+
'utf8',
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
expect(entitySource).toContain(
|
|
76
|
+
'create index "ai_chat_conv_participants_active_conv_user_idx" on "ai_chat_conversation_participants" ("tenant_id", "organization_id", "conversation_id", "user_id") where "deleted_at" is null',
|
|
77
|
+
)
|
|
78
|
+
})
|
|
79
|
+
|
|
41
80
|
it('keeps the module snapshot aligned with partial unique indexes', () => {
|
|
42
81
|
const snapshot = JSON.parse(
|
|
43
82
|
readFileSync(
|
|
@@ -789,6 +789,11 @@ export class AiChatConversation {
|
|
|
789
789
|
expression:
|
|
790
790
|
'create unique index "ai_chat_conv_participants_tenant_conv_user_null_org_uq" on "ai_chat_conversation_participants" ("tenant_id", "conversation_id", "user_id") where "organization_id" is null',
|
|
791
791
|
})
|
|
792
|
+
@Index({
|
|
793
|
+
name: 'ai_chat_conv_participants_active_conv_user_idx',
|
|
794
|
+
expression:
|
|
795
|
+
'create index "ai_chat_conv_participants_active_conv_user_idx" on "ai_chat_conversation_participants" ("tenant_id", "organization_id", "conversation_id", "user_id") where "deleted_at" is null',
|
|
796
|
+
})
|
|
792
797
|
@Index({
|
|
793
798
|
name: 'ai_chat_conv_participants_tenant_org_user_conv_idx',
|
|
794
799
|
properties: ['tenantId', 'organizationId', 'userId', 'conversationId'],
|
|
@@ -349,6 +349,118 @@ describe('resolveAttachmentParts — tenant / org scope enforcement', () => {
|
|
|
349
349
|
expect(parts).toEqual([])
|
|
350
350
|
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('not found'))
|
|
351
351
|
})
|
|
352
|
+
|
|
353
|
+
// Regression for #2663 — null-scoped rows used to bypass the tenant check via
|
|
354
|
+
// a truthiness short-circuit and leak into the caller's LLM context.
|
|
355
|
+
it('drops a null-tenant (global) record for a non super-admin caller', async () => {
|
|
356
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(
|
|
357
|
+
makeRow({
|
|
358
|
+
id: 'null-tenant-1',
|
|
359
|
+
tenantId: null,
|
|
360
|
+
organizationId: null,
|
|
361
|
+
mimeType: 'image/png',
|
|
362
|
+
fileSize: 64,
|
|
363
|
+
content: 'secret OCR text',
|
|
364
|
+
}),
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
const parts = await resolveAttachmentParts({
|
|
368
|
+
attachmentIds: ['null-tenant-1'],
|
|
369
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
|
|
370
|
+
container: makeContainer(),
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
expect(parts).toEqual([])
|
|
374
|
+
expect(fsReadFileMock).not.toHaveBeenCalled()
|
|
375
|
+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('out of scope'))
|
|
376
|
+
})
|
|
377
|
+
|
|
378
|
+
// Regression for #2663 — `tenantId='X', organizationId=null` used to be
|
|
379
|
+
// readable by every org within tenant X.
|
|
380
|
+
it('drops a null-org record in the caller tenant for an org-scoped caller', async () => {
|
|
381
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(
|
|
382
|
+
makeRow({
|
|
383
|
+
id: 'null-org-1',
|
|
384
|
+
tenantId: 'tenant-1',
|
|
385
|
+
organizationId: null,
|
|
386
|
+
mimeType: 'image/png',
|
|
387
|
+
fileSize: 64,
|
|
388
|
+
}),
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
const parts = await resolveAttachmentParts({
|
|
392
|
+
attachmentIds: ['null-org-1'],
|
|
393
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
|
|
394
|
+
container: makeContainer(),
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
expect(parts).toEqual([])
|
|
398
|
+
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('out of scope'))
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it('lets a tenant-wide caller (null org) read any org within its tenant', async () => {
|
|
402
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(
|
|
403
|
+
makeRow({
|
|
404
|
+
id: 'same-tenant-other-org',
|
|
405
|
+
tenantId: 'tenant-1',
|
|
406
|
+
organizationId: 'org-OTHER',
|
|
407
|
+
mimeType: 'application/zip',
|
|
408
|
+
fileSize: 64,
|
|
409
|
+
content: null,
|
|
410
|
+
}),
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
const parts = await resolveAttachmentParts({
|
|
414
|
+
attachmentIds: ['same-tenant-other-org'],
|
|
415
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: null }),
|
|
416
|
+
container: makeContainer(),
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
expect(parts).toHaveLength(1)
|
|
420
|
+
expect(parts[0].attachmentId).toBe('same-tenant-other-org')
|
|
421
|
+
})
|
|
422
|
+
|
|
423
|
+
it('scopes the ORM query by tenant + org for a non super-admin caller', async () => {
|
|
424
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(makeRow({ id: 'att-scope-1', fileSize: 0 }))
|
|
425
|
+
|
|
426
|
+
await resolveAttachmentParts({
|
|
427
|
+
attachmentIds: ['att-scope-1'],
|
|
428
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: 'org-1' }),
|
|
429
|
+
container: makeContainer(),
|
|
430
|
+
})
|
|
431
|
+
|
|
432
|
+
const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
|
|
433
|
+
expect(whereArg).toEqual({ id: 'att-scope-1', tenantId: 'tenant-1', organizationId: 'org-1' })
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
it('omits org from the ORM query for a tenant-wide caller (null org)', async () => {
|
|
437
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(makeRow({ id: 'att-scope-2', fileSize: 0 }))
|
|
438
|
+
|
|
439
|
+
await resolveAttachmentParts({
|
|
440
|
+
attachmentIds: ['att-scope-2'],
|
|
441
|
+
authContext: makeAuth({ tenantId: 'tenant-1', organizationId: null }),
|
|
442
|
+
container: makeContainer(),
|
|
443
|
+
})
|
|
444
|
+
|
|
445
|
+
const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
|
|
446
|
+
expect(whereArg).toEqual({ id: 'att-scope-2', tenantId: 'tenant-1' })
|
|
447
|
+
expect(whereArg).not.toHaveProperty('organizationId')
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it('does not scope the ORM query for super-admin callers', async () => {
|
|
451
|
+
findOneWithDecryptionMock.mockResolvedValueOnce(
|
|
452
|
+
makeRow({ id: 'att-scope-3', tenantId: 'tenant-OTHER', organizationId: 'org-OTHER', fileSize: 0 }),
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
await resolveAttachmentParts({
|
|
456
|
+
attachmentIds: ['att-scope-3'],
|
|
457
|
+
authContext: makeAuth({ isSuperAdmin: true }),
|
|
458
|
+
container: makeContainer(),
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
const whereArg = findOneWithDecryptionMock.mock.calls[0][2]
|
|
462
|
+
expect(whereArg).toEqual({ id: 'att-scope-3' })
|
|
463
|
+
})
|
|
352
464
|
})
|
|
353
465
|
|
|
354
466
|
describe('resolveAttachmentParts — unavailable service graceful skip', () => {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createMcpClient } from '../client-factory'
|
|
2
|
+
|
|
3
|
+
const mockConnect = jest.fn(async () => ({ listTools: jest.fn(), callTool: jest.fn(), close: jest.fn() }))
|
|
4
|
+
|
|
5
|
+
jest.mock('../mcp-client', () => ({
|
|
6
|
+
McpClient: {
|
|
7
|
+
connect: (...args: unknown[]) => mockConnect(...args),
|
|
8
|
+
},
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
describe('createMcpClient stdio mode (issue #2669)', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks()
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('never places the API key on the default argv', async () => {
|
|
17
|
+
await createMcpClient({ mode: 'stdio', apiKeySecret: 'omk_secret.value' })
|
|
18
|
+
|
|
19
|
+
expect(mockConnect).toHaveBeenCalledTimes(1)
|
|
20
|
+
const options = mockConnect.mock.calls[0][0] as { apiKeySecret: string; args?: string[] }
|
|
21
|
+
|
|
22
|
+
// The secret is forwarded to McpClient.connect, which delivers it via env.
|
|
23
|
+
expect(options.apiKeySecret).toBe('omk_secret.value')
|
|
24
|
+
|
|
25
|
+
// The factory must not build default args that embed the secret. Leaving
|
|
26
|
+
// args undefined lets connectStdio use its secret-free default.
|
|
27
|
+
expect(options.args).toBeUndefined()
|
|
28
|
+
expect(JSON.stringify(options.args ?? [])).not.toContain('--api-key')
|
|
29
|
+
expect(JSON.stringify(options.args ?? [])).not.toContain('omk_secret.value')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('honors explicitly provided stdio args without injecting the secret', async () => {
|
|
33
|
+
await createMcpClient({
|
|
34
|
+
mode: 'stdio',
|
|
35
|
+
apiKeySecret: 'omk_secret.value',
|
|
36
|
+
stdioArgs: ['node', 'custom-server.js'],
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
const options = mockConnect.mock.calls[0][0] as { args?: string[] }
|
|
40
|
+
expect(options.args).toEqual(['node', 'custom-server.js'])
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
CODE_MODE_MAX_MUTATION_CALLS,
|
|
8
8
|
CODE_MODE_REQUIRED_FEATURES,
|
|
9
9
|
createApiRequestFn,
|
|
10
|
+
isUnsafeApiRequestPath,
|
|
10
11
|
isUnsafeHttpMethod,
|
|
11
12
|
matchApiEndpointPath,
|
|
12
13
|
} from '../codemode-tools'
|
|
@@ -287,6 +288,112 @@ describe('authorizeCodeModeApiRequest', () => {
|
|
|
287
288
|
})
|
|
288
289
|
})
|
|
289
290
|
|
|
291
|
+
describe('path traversal hardening (issue #2667)', () => {
|
|
292
|
+
describe('isUnsafeApiRequestPath', () => {
|
|
293
|
+
it.each([
|
|
294
|
+
'/api/foo/../admin',
|
|
295
|
+
'/api/foo/..',
|
|
296
|
+
'/api/foo/./admin',
|
|
297
|
+
'/api/.',
|
|
298
|
+
'/api/foo/%2e%2e/admin',
|
|
299
|
+
'/api/foo/%2E%2E/admin',
|
|
300
|
+
'/api/foo/%2e./admin',
|
|
301
|
+
'/api/foo/.%2e/admin',
|
|
302
|
+
'/api/foo/%2e/admin',
|
|
303
|
+
'/api/foo\\admin',
|
|
304
|
+
'/api/foo%2fadmin',
|
|
305
|
+
'/api/foo%2Fadmin',
|
|
306
|
+
'/api/foo%5cadmin',
|
|
307
|
+
'foo/../admin',
|
|
308
|
+
'/api/foo/../admin?expand=1',
|
|
309
|
+
// Raw ASCII tab/newline/CR are stripped by the WHATWG URL parser before
|
|
310
|
+
// the fetch, so `.<ctrl>.` collapses to `..` on the wire.
|
|
311
|
+
'/api/foo/.\t./admin',
|
|
312
|
+
'/api/foo/.\n./admin',
|
|
313
|
+
'/api/foo/.\r./admin',
|
|
314
|
+
'/api/foo/\t../admin',
|
|
315
|
+
])('flags %s as unsafe', (path) => {
|
|
316
|
+
expect(isUnsafeApiRequestPath(path)).toBe(true)
|
|
317
|
+
})
|
|
318
|
+
|
|
319
|
+
it.each([
|
|
320
|
+
'/api/customers/companies',
|
|
321
|
+
'/api/customers/companies/company-1',
|
|
322
|
+
'customers/companies/company-1?expand=1',
|
|
323
|
+
'/api/catalog/products/1.2.3',
|
|
324
|
+
'/api/customers/companies/00000000-0000-0000-0000-000000000000',
|
|
325
|
+
])('treats %s as safe', (path) => {
|
|
326
|
+
expect(isUnsafeApiRequestPath(path)).toBe(false)
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('denies a traversal path even when a parameterized endpoint would match', async () => {
|
|
331
|
+
mockedGetApiEndpoints.mockReset()
|
|
332
|
+
// A documented read the user is authorized for. The un-normalized segment
|
|
333
|
+
// matcher would have accepted '/api/foo/..' against '/api/foo/{id}', then
|
|
334
|
+
// the wire fetch would collapse it to '/api/admin'. The guard blocks it.
|
|
335
|
+
mockedGetApiEndpoints.mockResolvedValue([
|
|
336
|
+
{
|
|
337
|
+
id: 'get_foo',
|
|
338
|
+
operationId: 'get_foo',
|
|
339
|
+
method: 'GET',
|
|
340
|
+
path: '/api/foo/{id}',
|
|
341
|
+
summary: '',
|
|
342
|
+
description: '',
|
|
343
|
+
tags: [],
|
|
344
|
+
requiredFeatures: [],
|
|
345
|
+
parameters: [],
|
|
346
|
+
requestBodySchema: null,
|
|
347
|
+
deprecated: false,
|
|
348
|
+
},
|
|
349
|
+
])
|
|
350
|
+
|
|
351
|
+
const result = await authorizeCodeModeApiRequest(createContext(), 'GET', '/api/foo/..')
|
|
352
|
+
|
|
353
|
+
expect(result).toEqual({
|
|
354
|
+
allowed: false,
|
|
355
|
+
statusCode: 403,
|
|
356
|
+
error: 'Code Mode rejected unsafe API path: GET /api/foo/..',
|
|
357
|
+
})
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it('never issues the wire request for a traversal path', async () => {
|
|
361
|
+
mockedGetApiEndpoints.mockReset()
|
|
362
|
+
mockedFetchWithTimeout.mockReset()
|
|
363
|
+
mockedFetchWithTimeout.mockResolvedValue(okResponse())
|
|
364
|
+
mockedGetApiEndpoints.mockResolvedValue([
|
|
365
|
+
{
|
|
366
|
+
id: 'create_company',
|
|
367
|
+
operationId: 'create_company',
|
|
368
|
+
method: 'POST',
|
|
369
|
+
path: '/api/customers/companies',
|
|
370
|
+
summary: '',
|
|
371
|
+
description: '',
|
|
372
|
+
tags: [],
|
|
373
|
+
requiredFeatures: ['customers.companies.create'],
|
|
374
|
+
parameters: [],
|
|
375
|
+
requestBodySchema: null,
|
|
376
|
+
deprecated: false,
|
|
377
|
+
},
|
|
378
|
+
])
|
|
379
|
+
|
|
380
|
+
const apiRequest = createApiRequestFn(
|
|
381
|
+
createContext({ userFeatures: ['ai_assistant.view', 'customers.companies.create'] }),
|
|
382
|
+
() => {}
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
const result = (await apiRequest({
|
|
386
|
+
method: 'POST',
|
|
387
|
+
path: '/api/customers/companies/../admin',
|
|
388
|
+
body: {},
|
|
389
|
+
})) as { success: boolean; statusCode: number }
|
|
390
|
+
|
|
391
|
+
expect(result.success).toBe(false)
|
|
392
|
+
expect(result.statusCode).toBe(403)
|
|
393
|
+
expect(mockedFetchWithTimeout).not.toHaveBeenCalled()
|
|
394
|
+
})
|
|
395
|
+
})
|
|
396
|
+
|
|
290
397
|
describe('mutation call cap (issue #2724)', () => {
|
|
291
398
|
beforeEach(() => {
|
|
292
399
|
mockedGetApiEndpoints.mockReset()
|
|
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'
|
|
|
5
5
|
import * as registry from '@open-mercato/shared/modules/registry'
|
|
6
6
|
import {
|
|
7
7
|
rewriteGeneratedAliasImports,
|
|
8
|
+
escapeUnsafeJsStringChars,
|
|
8
9
|
findGeneratedFile,
|
|
9
10
|
ensureApiRouteManifestsRegistered,
|
|
10
11
|
} from '../generated-registry-loader'
|
|
@@ -13,6 +14,14 @@ function makeTempDir(): string {
|
|
|
13
14
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'om-gen-loader-'))
|
|
14
15
|
}
|
|
15
16
|
|
|
17
|
+
// Mirror the production sanitizer so expectations match how the rewriter
|
|
18
|
+
// embeds a resolved path into the generated source. For ordinary file URLs the
|
|
19
|
+
// escape is a no-op, but routing the expected literal through the same helper
|
|
20
|
+
// keeps the test honest if the rewriter's escaping ever changes.
|
|
21
|
+
function safeJsLiteral(value: string): string {
|
|
22
|
+
return escapeUnsafeJsStringChars(JSON.stringify(value))
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
describe('rewriteGeneratedAliasImports', () => {
|
|
17
26
|
// Regression for the MCP dev-server crash:
|
|
18
27
|
// "Cannot find package '@/.mercato' imported from .../tool-loader.js"
|
|
@@ -28,7 +37,7 @@ describe('rewriteGeneratedAliasImports', () => {
|
|
|
28
37
|
const expectedUrl = pathToFileURL(
|
|
29
38
|
path.join(appRoot, '.mercato/generated/entities'),
|
|
30
39
|
).href
|
|
31
|
-
expect(out).toBe(`import { x } from ${
|
|
40
|
+
expect(out).toBe(`import { x } from ${safeJsLiteral(expectedUrl)}`)
|
|
32
41
|
expect(out).not.toContain('@/')
|
|
33
42
|
})
|
|
34
43
|
|
|
@@ -40,7 +49,7 @@ describe('rewriteGeneratedAliasImports', () => {
|
|
|
40
49
|
const expectedUrl = pathToFileURL(
|
|
41
50
|
path.join(appRoot, '.mercato/generated/ai-tools.generated'),
|
|
42
51
|
).href
|
|
43
|
-
const expected = source.replace(aliasSpecifier,
|
|
52
|
+
const expected = source.replace(aliasSpecifier, safeJsLiteral(expectedUrl))
|
|
44
53
|
expect(out).toBe(expected)
|
|
45
54
|
expect(out).not.toContain('@/')
|
|
46
55
|
})
|
|
@@ -53,7 +62,7 @@ describe('rewriteGeneratedAliasImports', () => {
|
|
|
53
62
|
const out = rewriteGeneratedAliasImports(`import x from '@/src/thing'`, appRoot)
|
|
54
63
|
|
|
55
64
|
const expectedUrl = pathToFileURL(path.join(appRoot, 'src', 'thing.ts')).href
|
|
56
|
-
expect(out).toBe(`import x from ${
|
|
65
|
+
expect(out).toBe(`import x from ${safeJsLiteral(expectedUrl)}`)
|
|
57
66
|
})
|
|
58
67
|
|
|
59
68
|
it('leaves non-alias imports untouched', () => {
|
|
@@ -66,6 +75,24 @@ describe('rewriteGeneratedAliasImports', () => {
|
|
|
66
75
|
})
|
|
67
76
|
})
|
|
68
77
|
|
|
78
|
+
describe('escapeUnsafeJsStringChars', () => {
|
|
79
|
+
it('escapes characters JSON.stringify leaves intact in a JS string literal', () => {
|
|
80
|
+
const lineSep = String.fromCharCode(0x2028)
|
|
81
|
+
const paraSep = String.fromCharCode(0x2029)
|
|
82
|
+
const out = escapeUnsafeJsStringChars(`a<b>c${lineSep}d${paraSep}e`)
|
|
83
|
+
expect(out).toBe('a\\u003Cb\\u003Ec\\u2028d\\u2029e')
|
|
84
|
+
expect(out).not.toContain('<')
|
|
85
|
+
expect(out).not.toContain('>')
|
|
86
|
+
expect(out).not.toContain(lineSep)
|
|
87
|
+
expect(out).not.toContain(paraSep)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('leaves ordinary file URLs unchanged', () => {
|
|
91
|
+
const url = pathToFileURL(path.join('/tmp', 'app', '.mercato', 'x.generated')).href
|
|
92
|
+
expect(escapeUnsafeJsStringChars(JSON.stringify(url))).toBe(JSON.stringify(url))
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
69
96
|
describe('findGeneratedFile', () => {
|
|
70
97
|
let cwdSpy: jest.SpyInstance
|
|
71
98
|
|
|
@@ -58,7 +58,7 @@ describe('McpClient stdio transport', () => {
|
|
|
58
58
|
command: 'node',
|
|
59
59
|
args: ['server.js'],
|
|
60
60
|
cwd: '/tmp/open-mercato',
|
|
61
|
-
env: process.env,
|
|
61
|
+
env: { ...process.env, OPEN_MERCATO_API_KEY: 'test-secret' },
|
|
62
62
|
stderr: 'pipe',
|
|
63
63
|
})
|
|
64
64
|
expect(mockClientConnect).toHaveBeenCalledTimes(1)
|
|
@@ -71,4 +71,23 @@ describe('McpClient stdio transport', () => {
|
|
|
71
71
|
expect(mockTransportClose).toHaveBeenCalledTimes(1)
|
|
72
72
|
expect(mockManualChildKill).not.toHaveBeenCalled()
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
it('passes the API key via OPEN_MERCATO_API_KEY env, never on argv (issue #2669)', async () => {
|
|
76
|
+
await McpClient.connect({
|
|
77
|
+
transport: 'stdio',
|
|
78
|
+
apiKeySecret: 'omk_secret.value',
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
expect(StdioClientTransport).toHaveBeenCalledTimes(1)
|
|
82
|
+
const transportArgs = (StdioClientTransport as jest.Mock).mock.calls[0][0]
|
|
83
|
+
|
|
84
|
+
// The secret must reach the child process exclusively through the env var.
|
|
85
|
+
expect(transportArgs.env.OPEN_MERCATO_API_KEY).toBe('omk_secret.value')
|
|
86
|
+
|
|
87
|
+
// It must NOT appear on argv (world-readable via ps / /proc/<pid>/cmdline).
|
|
88
|
+
expect(transportArgs.args).toEqual(['mercato', 'ai_assistant', 'mcp:serve'])
|
|
89
|
+
expect(transportArgs.args).not.toContain('--api-key')
|
|
90
|
+
expect(transportArgs.args).not.toContain('omk_secret.value')
|
|
91
|
+
expect(JSON.stringify(transportArgs.args)).not.toContain('omk_secret.value')
|
|
92
|
+
})
|
|
74
93
|
})
|