codex.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224
  1. import os from 'node:os'
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import { describe, expect, it } from 'vitest'
  5. import type { AgentEventType, EventPayloads } from '@agent-server/shared'
  6. import { config } from '../src/config'
  7. import { CodexProvider } from '../src/providers/codex/adapter'
  8. import { NO_CODEX_PLUGINS, type CodexPluginGrant, type RunContext } from '../src/providers/types'
  9. import { resolveAccess } from '../src/services/access-policy'
  10. import type { InteractionOutcome, NewApproval, NewQuestion } from '../src/services/interactions'
  11. import { defaultSettings } from '../src/services/settings'
  12. const fake = path.resolve(__dirname, 'fixtures-fake-codex.mjs')
  13. const settings = defaultSettings()
  14. settings.providers.codex = { ...settings.providers.codex, enabled: true, cliPath: fake }
  15. const silent = { info: () => {}, warn: () => {}, error: () => {} }
  16. function makeCtx(text: string, opts: { providerSessionId?: string; mode?: 'ask' | 'auto' | 'readonly'; answer?: (r: NewApproval | NewQuestion) => InteractionOutcome; signal?: AbortSignal; codexPlugins?: CodexPluginGrant } = {}) {
  17. const ws = path.join(config.dataDir, 'workspaces', `ses_codex_${Math.random().toString(36).slice(2)}`)
  18. fs.mkdirSync(ws, { recursive: true })
  19. const events: { type: AgentEventType; payload: unknown }[] = []
  20. const interactions: (NewApproval | NewQuestion)[] = []
  21. const audits: string[] = []
  22. let sessionId: string | null = null
  23. const ctx: RunContext = {
  24. sessionId: 's', runId: 'r', model: 'gpt-test', permissionMode: opts.mode ?? 'ask', providerSessionId: opts.providerSessionId ?? null,
  25. workspaceDir: ws, systemPromptAppend: '说明', allowedTools: [], disallowedTools: [], skills: 'all', plugins: [], codexPlugins: opts.codexPlugins ?? NO_CODEX_PLUGINS, skillsExcludedFromAll: [],
  26. gateway: { url: 'http://127.0.0.1:9/mcp', token: 'rt_test' },
  27. input: { text, images: [], attachments: [] },
  28. access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: ['**/*.env'] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
  29. settings, signal: opts.signal ?? new AbortController().signal,
  30. emit: <T extends AgentEventType>(type: T, payload: EventPayloads[T]) => { events.push({ type, payload }) },
  31. interact: async r => { interactions.push(r); return opts.answer?.(r) ?? { resolution: 'allowed', scope: 'once' } },
  32. setProviderSessionId: id => { sessionId = id },
  33. audit: (kind, summary) => audits.push(`${kind}:${summary}`),
  34. log: silent,
  35. }
  36. return { ctx, events, interactions, audits, sessionId: () => sessionId }
  37. }
  38. describe('CodexProvider(模拟 app-server)', () => {
  39. const provider = new CodexProvider(silent)
  40. it('映射文本、命令与审批、用量;传入按 Profile 生成的权限配置', async () => {
  41. const t = makeCtx('请执行命令')
  42. const r = await provider.run(t.ctx)
  43. expect(t.sessionId()).toMatch(/^t-/)
  44. const types = t.events.map(e => e.type)
  45. expect(types).toEqual(expect.arrayContaining(['tool.started', 'tool.completed', 'message.delta', 'message.completed']))
  46. expect(t.interactions[0]).toMatchObject({ kind: 'approval', tool: 'Bash', detail: { command: 'ls -la' } })
  47. expect(t.events.find(e => e.type === 'tool.completed')!.payload).toMatchObject({ status: 'ok', output: 'a.csv' })
  48. // 本次用量 = 最新累计 - (首次累计 - 首次增量) = 220-100, 45-20
  49. // 上下文大小取最后一次调用的输入 + 输出
  50. expect(r.usage).toEqual({ inputTokens: 120, outputTokens: 25, cacheReadTokens: 0, cacheWriteTokens: 0, contextTokens: 85 })
  51. const cfg = JSON.parse((t.events.find(e => e.type === 'message.completed' && (e.payload as { messageId: string }).messageId === 'cfg')!.payload as { text: string }).text)
  52. const fsTable = cfg.permissions.agent_server.filesystem
  53. expect(cfg.default_permissions).toBe('agent_server')
  54. expect(fsTable[':root']).toBe('deny')
  55. expect(fsTable[':workspace_roots']).toEqual({ '.': 'write', '**/*.env': 'deny' })
  56. expect(fsTable[path.join(config.home, '.ssh')]).toBe('deny')
  57. expect(cfg.permissions.agent_server.network.enabled).toBe(false)
  58. expect(cfg.shell_environment_policy.inherit).toBe('core')
  59. // Profile 选择 all:禁用始终禁用的系统技能;没有开放插件时远程插件的技能也禁用
  60. expect(cfg.skills).toEqual({ config: [
  61. { path: '/sys/skill-installer/SKILL.md', enabled: false }, { path: '/plugins/google-drive/skills/google-docs/SKILL.md', enabled: false },
  62. ] })
  63. // 插件、应用连接器、插件安装建议与子代理在服务中关闭
  64. expect(cfg.features).toMatchObject({ plugins: false, remote_plugin: false, apps: false, tool_suggest: false, multi_agent: false })
  65. expect(cfg.apps._default.enabled).toBe(false)
  66. expect(cfg.agents).toEqual({ enabled: false })
  67. // 网关以 HTTP MCP 方式配置,携带本次 Run 的令牌
  68. expect(cfg.mcp_servers.gateway).toMatchObject({ url: 'http://127.0.0.1:9/mcp', http_headers: { Authorization: 'Bearer rt_test' }, default_tools_approval_mode: 'approve' })
  69. })
  70. it('Playground:开放插件与应用连接器,应用调用确认转为审批卡片', async () => {
  71. const t = makeCtx('调用应用', { codexPlugins: 'all' })
  72. await provider.run(t.ctx)
  73. const cfg = JSON.parse((t.events.find(e => (e.payload as { messageId?: string }).messageId === 'cfg')!.payload as { text: string }).text)
  74. expect(cfg.features).toMatchObject({ plugins: true, remote_plugin: true, apps: true, tool_suggest: false, multi_agent: false })
  75. expect(cfg.apps._default.enabled).toBe(true)
  76. // Playground 中子代理同样关闭
  77. expect(cfg.agents).toEqual({ enabled: false })
  78. expect(cfg.skills).toEqual({ config: [{ path: '/sys/skill-installer/SKILL.md', enabled: false }] })
  79. expect(t.interactions[0]).toMatchObject({ kind: 'approval', tool: 'mcp__codex_apps', title: '允许调用 Gmail 的 search_emails?' })
  80. expect(t.events.some(e => (e.payload as { text?: string }).text === 'app:accept')).toBe(true)
  81. })
  82. it('Profile 勾选的插件:只启用这些插件的应用与技能,应用调用确认转为审批卡片', async () => {
  83. const t = makeCtx('调用应用', { codexPlugins: { pluginIds: ['google-drive@openai-curated-remote'], appIds: ['connector_drive'] } })
  84. await provider.run(t.ctx)
  85. const cfg = JSON.parse((t.events.find(e => (e.payload as { messageId?: string }).messageId === 'cfg')!.payload as { text: string }).text)
  86. expect(cfg.features).toMatchObject({ plugins: true, remote_plugin: true, apps: true, tool_suggest: false })
  87. expect(cfg.apps).toEqual({ _default: { enabled: false, destructive_enabled: false, open_world_enabled: false }, connector_drive: { enabled: true } })
  88. // 勾选的插件带来的技能保留
  89. expect(cfg.skills).toEqual({ config: [{ path: '/sys/skill-installer/SKILL.md', enabled: false }] })
  90. expect(t.interactions[0]).toMatchObject({ kind: 'approval', tool: 'mcp__codex_apps' })
  91. const other = makeCtx('调用应用', { codexPlugins: { pluginIds: ['gmail@openai-curated-remote'], appIds: ['connector_gmail'] } })
  92. await provider.run(other.ctx)
  93. const cfg2 = JSON.parse((other.events.find(e => (e.payload as { messageId?: string }).messageId === 'cfg')!.payload as { text: string }).text)
  94. // 未勾选的插件(google-drive)的技能被禁用
  95. expect(cfg2.skills.config.map((s: { path: string }) => s.path)).toContain('/plugins/google-drive/skills/google-docs/SKILL.md')
  96. })
  97. it('API 会话:应用调用确认直接拒绝', async () => {
  98. const t = makeCtx('调用应用')
  99. await provider.run(t.ctx)
  100. expect(t.interactions).toHaveLength(0)
  101. expect(t.events.some(e => (e.payload as { text?: string }).text === 'app:decline')).toBe(true)
  102. })
  103. it('插件目录:只列出 openai-curated,搜索、安装、卸载', async () => {
  104. const { CodexPluginService } = await import('../src/services/codex-plugins')
  105. const audits: string[] = []
  106. const svc = new CodexPluginService(() => provider, () => settings, s => audits.push(s))
  107. const all = await svc.catalog({})
  108. expect(all.items.map(p => [p.name, p.installed])).toEqual([['gmail', true], ['notion', false], ['blocked', false]])
  109. expect(all.categories).toEqual(['Docs', 'Productivity'])
  110. expect((await svc.catalog({ query: 'notion' })).items.map(p => p.displayName)).toEqual(['Notion'])
  111. expect((await svc.catalog({ installed: true })).total).toBe(1)
  112. expect(await svc.install('notion')).toEqual({ appsNeedingAuth: [{ id: 'connector_n', name: 'Notion', installUrl: 'https://chatgpt.com/apps/notion' }] })
  113. await expect(svc.install('blocked')).rejects.toThrow(/不可安装/)
  114. // Codex 的错误原样转出(502),不是"服务器内部错误"
  115. await expect((svc as unknown as { call: (m: string, p: unknown) => Promise<unknown> }).call('plugin/install', { remoteMarketplaceName: 'openai-curated-remote', pluginName: 'notion' }))
  116. .rejects.toMatchObject({ status: 502, code: 'codex_error', message: expect.stringContaining('Plugin not found') })
  117. await expect(svc.install('other-plugin')).rejects.toThrow(/不存在/)
  118. await expect(svc.install('../x')).rejects.toThrow(/格式/)
  119. // Profile 勾选的插件 → 开放的插件与应用(未安装的忽略)
  120. expect(await svc.grant(['gmail@openai-curated-remote', 'notion@openai-curated-remote'])).toEqual({ pluginIds: ['gmail@openai-curated-remote'], appIds: ['connector_gmail'] })
  121. expect(await svc.grant([])).toEqual({ pluginIds: [], appIds: [] })
  122. await svc.uninstall('gmail')
  123. expect((await svc.catalog({})).items.find(p => p.name === 'gmail')!.installed).toBe(false)
  124. expect(audits).toEqual(['安装 Codex 插件 Notion(notion@openai-curated-remote)', '卸载 Codex 插件 GMAIL(gmail@openai-curated-remote)'])
  125. })
  126. it('拒绝审批时工具标记为 denied', async () => {
  127. const t = makeCtx('请执行命令', { answer: () => ({ resolution: 'denied' }) })
  128. await provider.run(t.ctx)
  129. expect(t.events.find(e => e.type === 'tool.completed')!.payload).toMatchObject({ status: 'denied' })
  130. })
  131. it('命令联网请求一律拒绝并审计,不打扰用户', async () => {
  132. const t = makeCtx('需要联网')
  133. await provider.run(t.ctx)
  134. expect(t.interactions).toHaveLength(0)
  135. expect(t.events.some(e => (e.payload as { text?: string }).text === 'network:decline')).toBe(true)
  136. expect(t.audits.some(a => a.startsWith('network_denied'))).toBe(true)
  137. })
  138. it('提问映射为问题卡片,回答按 questionId 返回', async () => {
  139. const t = makeCtx('请提问', { answer: () => ({ resolution: 'answered', answers: { chart: '折线图' } }) })
  140. await provider.run(t.ctx)
  141. expect(t.interactions[0]).toMatchObject({ kind: 'question', questions: [{ id: 'chart', options: [{ label: '柱状图' }, { label: '折线图' }] }] })
  142. expect(t.events.some(e => (e.payload as { text?: string }).text === 'answer:{"chart":{"answers":["折线图"]}}')).toBe(true)
  143. })
  144. it('同一会话配置不变时沿用;配置变化时分叉出新会话并采用新配置', async () => {
  145. const t1 = makeCtx('你好')
  146. await provider.run(t1.ctx)
  147. const first = t1.sessionId()!
  148. const t2 = makeCtx('你好', { providerSessionId: first })
  149. t2.ctx.workspaceDir = t1.ctx.workspaceDir
  150. t2.ctx.access = t1.ctx.access
  151. await provider.run(t2.ctx)
  152. expect(t2.sessionId()).toBeNull() // 未改变会话 ID
  153. // 改为只读:工作区权限从 write 变为 read
  154. const t3 = makeCtx('你好', { providerSessionId: first, mode: 'readonly' })
  155. t3.ctx.workspaceDir = t1.ctx.workspaceDir
  156. t3.ctx.access = t1.ctx.access
  157. await provider.run(t3.ctx)
  158. expect(t3.sessionId()).toMatch(/^f-/)
  159. const cfg = JSON.parse((t3.events.find(e => (e.payload as { messageId?: string }).messageId === 'cfg')!.payload as { text: string }).text)
  160. expect(cfg.permissions.agent_server.filesystem[':workspace_roots']['.']).toBe('read')
  161. })
  162. it('恢复不存在的会话时新建;中断正在运行的 turn', async () => {
  163. const t = makeCtx('你好', { providerSessionId: 't-999' })
  164. await provider.run(t.ctx)
  165. expect(t.sessionId()).toMatch(/^t-\d+$/)
  166. expect(t.sessionId()).not.toBe('t-999')
  167. const ac = new AbortController()
  168. const t2 = makeCtx('等待', { signal: ac.signal })
  169. const p = provider.run(t2.ctx)
  170. setTimeout(() => ac.abort(), 200)
  171. await expect(p).rejects.toThrow(/中断/)
  172. })
  173. it('设备码登录、账户状态与模型列表', async () => {
  174. const before = await provider.status(settings)
  175. expect(before.loggedIn).toBe(false)
  176. const login = await provider.startLogin(settings)
  177. expect(login).toMatchObject({ verificationUrl: 'https://auth.example.com/device', userCode: 'ABCD-1234', status: 'pending' })
  178. await new Promise(r => setTimeout(r, 300))
  179. expect(provider.loginState()?.status).toBe('success')
  180. const after = await provider.status(settings)
  181. expect(after).toMatchObject({ loggedIn: true, available: true, account: 'me@example.com (plus)', version: '9.9.9', models: ['gpt-test'] })
  182. expect(provider.knownModels()).toEqual(['gpt-test'])
  183. })
  184. })
  185. describe('commandTitle', () => {
  186. it('去掉 Codex 的 shell 包装', async () => {
  187. const { commandTitle } = await import('../src/providers/codex/adapter')
  188. expect(commandTitle(`/usr/bin/bash -lc "python - <<'PY'\nimport x\nPY"`)).toBe(`python - <<'PY'`)
  189. expect(commandTitle(`/usr/bin/bash -lc 'curl -I https://example.com'`)).toBe('curl -I https://example.com')
  190. expect(commandTitle('ls -la')).toBe('ls -la')
  191. })
  192. })
  193. describe('Codex 权限:父目录禁止、子路径可读', () => {
  194. it('denyAround:逐层展开,只留下通往可读路径的目录', async () => {
  195. const { denyAround } = await import('../src/providers/codex/permissions')
  196. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'deny-'))
  197. for (const d of ['data/custom-skills/skills', 'data/community-skills', 'data/mcp', 'workspaces/a']) fs.mkdirSync(path.join(root, d), { recursive: true })
  198. fs.writeFileSync(path.join(root, 'data', 'agent.db'), '')
  199. const keep = [path.join(root, 'data', 'custom-skills'), path.join(root, 'data', 'community-skills')]
  200. expect(denyAround(path.join(root, 'data'), keep).map(p => path.relative(root, p)).sort()).toEqual(['data/agent.db', 'data/mcp'])
  201. expect(denyAround(root, keep).map(p => path.relative(root, p)).sort()).toEqual(['data/agent.db', 'data/mcp', 'workspaces'])
  202. expect(denyAround(path.join(root, 'workspaces'), keep)).toEqual([path.join(root, 'workspaces')])
  203. })
  204. })