import { createServer, type Server } from 'node:http'; import { mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { CodexRuntime } from './codexRuntime'; import { ensureCodexHome, getSkillsDir } from './codexHome'; import { McpService } from './mcpService'; import { SkillService } from './skillService'; import { applyProvider, clearProvider, readAppliedProvider } from './providerService'; /** * 集成冒烟:真实 spawn codex app-server,但不需要账号登录、不需要真实模型端点。 * 默认 npm test 不跑(.itest.ts),用 npm run smoke:codex 单独跑。 */ const API_KEY = 'sk-smoke-secret-value-1234567890'; const SKILL_MD = ['---', 'name: smoke-skill', 'description: 冒烟测试用的技能', '---', '', '# smoke', ''].join('\n'); let server: Server; let endpointStatus = 401; let baseUrl: string; let codexHome: string; let runtime: CodexRuntime; let tmpRoot: string; beforeAll(async () => { // 假端点:401 表示「路由存在但需要鉴权」,足以让 Chat 探测判为可用 server = createServer((req, res) => { req.resume(); res.writeHead(endpointStatus, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: { message: 'smoke endpoint' } })); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; baseUrl = `http://127.0.0.1:${port}/v1`; codexHome = await ensureCodexHome(); tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-codex-smoke-')); runtime = new CodexRuntime({ codexHome, provider: { id: 'smoke', name: '冒烟端点', baseUrl, model: 'smoke-model', apiKey: API_KEY }, }); await runtime.start(); }, 120_000); afterAll(async () => { await runtime?.dispose(); await new Promise((resolve) => server?.close(() => resolve())); await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined); }); describe('免登录启动与配置注入', () => { it('自定义 provider 完成握手,config 里没有 api key', async () => { const status = runtime.status; expect(status.state).toBe('ready'); expect(status.version).toMatch(/codex-cli/); expect(status.providerId).toBe('smoke'); const config = await runtime.readConfig(); expect(config.model_provider).toBe('smoke'); expect(config.model).toBe('smoke-model'); const entry = (config.model_providers as Record>)?.smoke; expect(entry?.base_url).toBe(baseUrl); expect(entry?.wire_api).toBe('responses'); expect(entry?.requires_openai_auth).toBe(false); expect(entry?.env_key).toBe('ZSJZ_CODEX_API_KEY'); expect(JSON.stringify(config)).not.toContain(API_KEY); }); it('defaultModel 用 provider 配置的模型,不被内置目录默认值覆盖', () => { expect(runtime.status.defaultModel).toBe('smoke-model'); expect(Array.isArray(runtime.status.models)).toBe(true); }); it('未登录也能列 skill 与 MCP 状态', async () => { const skills = await runtime.listSkills({ forceReload: true }); expect(Array.isArray(skills)).toBe(true); expect(skills.some((skill) => skill.scope === 'system')).toBe(true); expect(Array.isArray(await runtime.listMcpServerStatuses())).toBe(true); }); }); describe('MCP 配置热生效', () => { it('保存 → 出现在 config 与列表 → 删除后消失,全程不重启', async () => { const service = new McpService(runtime); const before = await service.list(); const saved = await service.save({ id: 'smoke-stdio', transport: 'stdio', command: 'node', args: ['-e', 'process.exit(0)'], enabled: false, }); const entry = saved.find((item) => item.id === 'smoke-stdio'); expect(entry).toMatchObject({ transport: 'stdio', command: 'node', enabled: false }); expect(saved.length).toBe(before.length + 1); const config = await runtime.readConfig(); const servers = config.mcp_servers as Record>; expect(servers['smoke-stdio']).toMatchObject({ command: 'node', enabled: false }); const afterRemove = await service.remove('smoke-stdio'); expect(afterRemove.find((item) => item.id === 'smoke-stdio')).toBeUndefined(); expect(runtime.status.state).toBe('ready'); }); it('http 传输写入 url 与 bearer_token_env_var', async () => { const service = new McpService(runtime); await service.save({ id: 'smoke-http', transport: 'http', url: 'http://127.0.0.1:9999/mcp', enabled: false, bearerTokenEnvVar: 'ZSJZ_SMOKE_TOKEN', }); const config = await runtime.readConfig(); const servers = config.mcp_servers as Record>; expect(servers['smoke-http']).toMatchObject({ url: 'http://127.0.0.1:9999/mcp', bearer_token_env_var: 'ZSJZ_SMOKE_TOKEN', }); await service.remove('smoke-http'); }); }); describe('Skill 安装能被 Codex 识别', () => { it('装目录 → skills/list 可见 → 启停 → 卸载', async () => { const service = new SkillService(runtime); const source = join(tmpRoot, 'skill-src'); await mkdir(join(source, 'references'), { recursive: true }); await writeFile(join(source, 'SKILL.md'), SKILL_MD, 'utf8'); await writeFile(join(source, 'references', 'note.md'), 'note', 'utf8'); await service.installFromFolder(source, { overwrite: true }); const installedDir = join(getSkillsDir(), 'smoke-skill'); expect((await stat(join(installedDir, 'SKILL.md'))).isFile()).toBe(true); // 关键断言:我们自己写的目录布局确实被 Codex 认出来 const listed = await service.list({ forceReload: true }); const installed = listed.find((item) => item.name === 'smoke-skill'); expect(installed).toBeTruthy(); expect(installed?.managed).toBe(true); expect(installed?.description).toBe('冒烟测试用的技能'); expect(await service.setEnabled({ name: 'smoke-skill' }, false)).toBe(false); const disabled = await service.list({ forceReload: true }); expect(disabled.find((item) => item.name === 'smoke-skill')?.enabled).toBe(false); expect(await service.setEnabled({ name: 'smoke-skill' }, true)).toBe(true); const read = await service.read({ name: 'smoke-skill' }); expect(read.content).toContain('smoke-skill'); expect(read.files.map((file) => file.path).sort()).toEqual(['SKILL.md', 'references/note.md']); await service.remove({ name: 'smoke-skill' }); expect(await stat(installedDir).catch(() => null)).toBeNull(); const afterRemove = await service.list({ forceReload: true }); expect(afterRemove.find((item) => item.name === 'smoke-skill')).toBeUndefined(); }); it('删除后 config 里的残留条目要真的被清掉', async () => { const service = new SkillService(runtime); const source = join(tmpRoot, 'stale-skill'); await mkdir(source, { recursive: true }); await writeFile(join(source, 'SKILL.md'), SKILL_MD.replace('smoke-skill', 'stale-skill'), 'utf8'); await service.installFromFolder(source, { overwrite: true }); await service.setEnabled({ name: 'stale-skill' }, false); const before = await runtime.readConfig(); expect(JSON.stringify(before)).toContain('stale-skill'); await service.remove({ name: 'stale-skill' }); const after = await runtime.readConfig(); expect(JSON.stringify(after)).not.toContain('stale-skill'); }, 120_000); it('内置 skill 被标记为不可管理', async () => { const service = new SkillService(runtime); const items = await service.list(); const builtin = items.find((item) => item.scope === 'system'); expect(builtin?.managed).toBe(false); if (builtin) { await expect(service.remove({ path: builtin.path })).rejects.toThrow(/内置或暂存目录/); } }); }); describe('切换 provider 会重启子进程', () => { it('applyProvider 后 config 更新且磁盘上没有密钥', async () => { endpointStatus = 401; const applied = await applyProvider(runtime, { modelId: 'smoke-model-2', name: '冒烟端点 2', baseUrl, apiKey: API_KEY, modelRecordId: 42, }); expect(applied).toMatchObject({ model: 'smoke-model-2', providerId: 'zsjz', modelRecordId: '42' }); expect(runtime.status.state).toBe('ready'); const config = await runtime.readConfig(); expect(config.model_provider).toBe('zsjz'); expect(config.model).toBe('smoke-model-2'); expect(JSON.stringify(config)).not.toContain(API_KEY); const persisted = await readAppliedProvider(); expect(persisted?.model).toBe('smoke-model-2'); expect(JSON.stringify(persisted)).not.toContain(API_KEY); }); it('应用直连上游真实地址,不做协议转换', async () => { endpointStatus = 200; const applied = await applyProvider(runtime, { modelId: 'smoke-responses-model', baseUrl, apiKey: API_KEY, }); expect(applied.baseUrl).toBe(baseUrl); const config = await runtime.readConfig(); expect(config.model_provider).toBe('zsjz'); const entry = (config.model_providers as Record> | undefined)?.zsjz; expect(entry?.base_url).toBe(baseUrl); expect(entry?.wire_api).toBe('responses'); // 空闲窗口必须显著大于 Codex 默认的 300 秒,否则长 prefill 会被判成流故障 expect(entry?.stream_idle_timeout_ms).toBe(1_800_000); expect(entry?.stream_max_retries).toBe(0); expect(entry?.request_max_retries).toBe(0); }); it('没有 Responses 路由的端点拒绝应用', async () => { endpointStatus = 404; await expect(applyProvider(runtime, { modelId: 'x', baseUrl, apiKey: API_KEY })).rejects.toThrow( /没有 \/responses/, ); }); it('clearProvider 回到无 provider 状态', async () => { await clearProvider(runtime); expect(await readAppliedProvider()).toBeNull(); expect(runtime.provider).toBeNull(); endpointStatus = 401; }); }); describe('停止与回收', () => { it('stop 后状态归零,未经懒启动的调用直接失败', async () => { await runtime.stop(); expect(runtime.status.state).toBe('stopped'); await expect(runtime.interruptTurn('thread-x', 'turn-x')).rejects.toThrow(/未运行/); }); it('诊断信息会屏蔽密钥形态的字符串', async () => { const { sanitizeDiagnostic } = await import('./codexRuntime'); expect(sanitizeDiagnostic('token sk-abcdefghij1234567890 leaked')).not.toContain('sk-abcdefghij1234567890'); expect(sanitizeDiagnostic('Authorization: Bearer abc.def-ghi')).toContain('[redacted]'); }); });