| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125 |
- import fs from 'node:fs'
- import path from 'node:path'
- import { describe, expect, it } from 'vitest'
- import type { AgentEventType, EventPayloads } from '@agent-server/shared'
- import { config } from '../src/config'
- import { CodexProvider } from '../src/providers/codex/adapter'
- import type { RunContext } from '../src/providers/types'
- import { resolveAccess } from '../src/services/access-policy'
- import type { InteractionOutcome, NewApproval, NewQuestion } from '../src/services/interactions'
- import { defaultSettings } from '../src/services/settings'
- const fake = path.resolve(__dirname, 'fixtures-fake-codex.mjs')
- const settings = defaultSettings()
- settings.providers.codex = { ...settings.providers.codex, enabled: true, cliPath: fake }
- const silent = { info: () => {}, warn: () => {}, error: () => {} }
- function makeCtx(text: string, opts: { providerSessionId?: string; mode?: 'ask' | 'auto' | 'readonly'; answer?: (r: NewApproval | NewQuestion) => InteractionOutcome; signal?: AbortSignal } = {}) {
- const ws = path.join(config.dataDir, 'workspaces', `ses_codex_${Math.random().toString(36).slice(2)}`)
- fs.mkdirSync(ws, { recursive: true })
- const events: { type: AgentEventType; payload: unknown }[] = []
- const interactions: (NewApproval | NewQuestion)[] = []
- const audits: string[] = []
- let sessionId: string | null = null
- const ctx: RunContext = {
- sessionId: 's', runId: 'r', model: 'gpt-test', permissionMode: opts.mode ?? 'ask', providerSessionId: opts.providerSessionId ?? null,
- workspaceDir: ws, systemPromptAppend: '说明', allowedTools: [], disallowedTools: [], skills: 'all', plugins: [],
- gateway: { url: 'http://127.0.0.1:9/mcp', token: 'rt_test' },
- input: { text, images: [], attachments: [] },
- access: resolveAccess({ policy: { mode: 'restricted', read: [], write: [], deny: ['**/*.env'] }, knowledgeRoots: [], selectedKnowledgeRootIds: [], workspaceDir: ws, settings }),
- settings, signal: opts.signal ?? new AbortController().signal,
- emit: <T extends AgentEventType>(type: T, payload: EventPayloads[T]) => { events.push({ type, payload }) },
- interact: async r => { interactions.push(r); return opts.answer?.(r) ?? { resolution: 'allowed', scope: 'once' } },
- setProviderSessionId: id => { sessionId = id },
- audit: (kind, summary) => audits.push(`${kind}:${summary}`),
- log: silent,
- }
- return { ctx, events, interactions, audits, sessionId: () => sessionId }
- }
- describe('CodexProvider(模拟 app-server)', () => {
- const provider = new CodexProvider(silent)
- it('映射文本、命令与审批、用量;传入按 Profile 生成的权限配置', async () => {
- const t = makeCtx('请执行命令')
- const r = await provider.run(t.ctx)
- expect(t.sessionId()).toMatch(/^t-/)
- const types = t.events.map(e => e.type)
- expect(types).toEqual(expect.arrayContaining(['tool.started', 'tool.completed', 'message.delta', 'message.completed']))
- expect(t.interactions[0]).toMatchObject({ kind: 'approval', tool: 'Bash', detail: { command: 'ls -la' } })
- expect(t.events.find(e => e.type === 'tool.completed')!.payload).toMatchObject({ status: 'ok', output: 'a.csv' })
- // 本次用量 = 最新累计 - (首次累计 - 首次增量) = 220-100, 45-20
- expect(r.usage).toEqual({ inputTokens: 120, outputTokens: 25, cacheReadTokens: 0, cacheWriteTokens: 0 })
- const cfg = JSON.parse((t.events.find(e => e.type === 'message.completed' && (e.payload as { messageId: string }).messageId === 'cfg')!.payload as { text: string }).text)
- const fsTable = cfg.permissions.agent_server.filesystem
- expect(cfg.default_permissions).toBe('agent_server')
- expect(fsTable[':root']).toBe('deny')
- expect(fsTable[':workspace_roots']).toEqual({ '.': 'write', '**/*.env': 'deny' })
- expect(fsTable[path.join(config.home, '.ssh')]).toBe('deny')
- expect(cfg.permissions.agent_server.network.enabled).toBe(false)
- expect(cfg.shell_environment_policy.inherit).toBe('core')
- // Profile 选择 all:只禁用始终禁用的系统技能
- expect(cfg.skills).toEqual({ config: [{ path: '/sys/skill-installer/SKILL.md', enabled: false }] })
- // 插件、应用连接器、插件安装建议与子代理在服务中关闭
- expect(cfg.features).toMatchObject({ plugins: false, remote_plugin: false, apps: false, tool_suggest: false, multi_agent: false })
- expect(cfg.apps._default.enabled).toBe(false)
- // 网关以 HTTP MCP 方式配置,携带本次 Run 的令牌
- 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' })
- })
- it('拒绝审批时工具标记为 denied', async () => {
- const t = makeCtx('请执行命令', { answer: () => ({ resolution: 'denied' }) })
- await provider.run(t.ctx)
- expect(t.events.find(e => e.type === 'tool.completed')!.payload).toMatchObject({ status: 'denied' })
- })
- it('命令联网请求一律拒绝并审计,不打扰用户', async () => {
- const t = makeCtx('需要联网')
- await provider.run(t.ctx)
- expect(t.interactions).toHaveLength(0)
- expect(t.events.some(e => (e.payload as { text?: string }).text === 'network:decline')).toBe(true)
- expect(t.audits.some(a => a.startsWith('network_denied'))).toBe(true)
- })
- it('提问映射为问题卡片,回答按 questionId 返回', async () => {
- const t = makeCtx('请提问', { answer: () => ({ resolution: 'answered', answers: { chart: '折线图' } }) })
- await provider.run(t.ctx)
- expect(t.interactions[0]).toMatchObject({ kind: 'question', questions: [{ id: 'chart', options: [{ label: '柱状图' }, { label: '折线图' }] }] })
- expect(t.events.some(e => (e.payload as { text?: string }).text === 'answer:{"chart":{"answers":["折线图"]}}')).toBe(true)
- })
- it('恢复不存在的会话时新建;中断正在运行的 turn', async () => {
- const t = makeCtx('你好', { providerSessionId: 't-999' })
- await provider.run(t.ctx)
- expect(t.sessionId()).toMatch(/^t-\d+$/)
- expect(t.sessionId()).not.toBe('t-999')
- const ac = new AbortController()
- const t2 = makeCtx('等待', { signal: ac.signal })
- const p = provider.run(t2.ctx)
- setTimeout(() => ac.abort(), 200)
- await expect(p).rejects.toThrow(/中断/)
- })
- it('设备码登录、账户状态与模型列表', async () => {
- const before = await provider.status(settings)
- expect(before.loggedIn).toBe(false)
- const login = await provider.startLogin(settings)
- expect(login).toMatchObject({ verificationUrl: 'https://auth.example.com/device', userCode: 'ABCD-1234', status: 'pending' })
- await new Promise(r => setTimeout(r, 300))
- expect(provider.loginState()?.status).toBe('success')
- const after = await provider.status(settings)
- expect(after).toMatchObject({ loggedIn: true, available: true, account: 'me@example.com (plus)', version: '9.9.9', models: ['gpt-test'] })
- expect(provider.knownModels()).toEqual(['gpt-test'])
- })
- })
- describe('commandTitle', () => {
- it('去掉 Codex 的 shell 包装', async () => {
- const { commandTitle } = await import('../src/providers/codex/adapter')
- expect(commandTitle(`/usr/bin/bash -lc "python - <<'PY'\nimport x\nPY"`)).toBe(`python - <<'PY'`)
- expect(commandTitle(`/usr/bin/bash -lc 'curl -I https://example.com'`)).toBe('curl -I https://example.com')
- expect(commandTitle('ls -la')).toBe('ls -la')
- })
- })
|