codexRuntime.itest.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. import { createServer, type Server } from 'node:http';
  2. import { mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises';
  3. import { tmpdir } from 'node:os';
  4. import { join } from 'node:path';
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest';
  6. import { CodexRuntime } from './codexRuntime';
  7. import { ensureCodexHome, getSkillsDir } from './codexHome';
  8. import { McpService } from './mcpService';
  9. import { SkillService } from './skillService';
  10. import { applyProvider, clearProvider, readAppliedProvider } from './providerService';
  11. /**
  12. * 集成冒烟:真实 spawn codex app-server,但不需要账号登录、不需要真实模型端点。
  13. * 默认 npm test 不跑(.itest.ts),用 npm run smoke:codex 单独跑。
  14. */
  15. const API_KEY = 'sk-smoke-secret-value-1234567890';
  16. const SKILL_MD = ['---', 'name: smoke-skill', 'description: 冒烟测试用的技能', '---', '', '# smoke', ''].join('\n');
  17. let server: Server;
  18. let responsesStatus = 401;
  19. let chatStatus = 401;
  20. let baseUrl: string;
  21. let codexHome: string;
  22. let runtime: CodexRuntime;
  23. let tmpRoot: string;
  24. beforeAll(async () => {
  25. // 假端点:401 表示「路由存在但需要鉴权」,足以让 provider 探测判为兼容
  26. server = createServer((req, res) => {
  27. req.resume();
  28. const status = req.url?.endsWith('/chat/completions') ? chatStatus : responsesStatus;
  29. res.writeHead(status, { 'content-type': 'application/json' });
  30. res.end(JSON.stringify({ error: { message: 'smoke endpoint' } }));
  31. });
  32. await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
  33. const address = server.address();
  34. const port = typeof address === 'object' && address ? address.port : 0;
  35. baseUrl = `http://127.0.0.1:${port}/v1`;
  36. codexHome = await ensureCodexHome();
  37. tmpRoot = await mkdtemp(join(tmpdir(), 'zsjz-codex-smoke-'));
  38. runtime = new CodexRuntime({
  39. codexHome,
  40. provider: { id: 'smoke', name: '冒烟端点', baseUrl, model: 'smoke-model', apiKey: API_KEY },
  41. });
  42. await runtime.start();
  43. }, 120_000);
  44. afterAll(async () => {
  45. await runtime?.dispose();
  46. await new Promise<void>((resolve) => server?.close(() => resolve()));
  47. await rm(tmpRoot, { recursive: true, force: true }).catch(() => undefined);
  48. });
  49. describe('免登录启动与配置注入', () => {
  50. it('自定义 provider 完成握手,config 里没有 api key', async () => {
  51. const status = runtime.status;
  52. expect(status.state).toBe('ready');
  53. expect(status.version).toMatch(/codex-cli/);
  54. expect(status.providerId).toBe('smoke');
  55. const config = await runtime.readConfig();
  56. expect(config.model_provider).toBe('smoke');
  57. expect(config.model).toBe('smoke-model');
  58. const entry = (config.model_providers as Record<string, Record<string, unknown>>)?.smoke;
  59. expect(entry?.base_url).toBe(baseUrl);
  60. expect(entry?.wire_api).toBe('responses');
  61. expect(entry?.requires_openai_auth).toBe(false);
  62. expect(entry?.env_key).toBe('ZSJZ_CODEX_API_KEY');
  63. expect(JSON.stringify(config)).not.toContain(API_KEY);
  64. });
  65. it('defaultModel 用 provider 配置的模型,不被内置目录默认值覆盖', () => {
  66. expect(runtime.status.defaultModel).toBe('smoke-model');
  67. expect(Array.isArray(runtime.status.models)).toBe(true);
  68. });
  69. it('未登录也能列 skill 与 MCP 状态', async () => {
  70. const skills = await runtime.listSkills({ forceReload: true });
  71. expect(Array.isArray(skills)).toBe(true);
  72. expect(skills.some((skill) => skill.scope === 'system')).toBe(true);
  73. expect(Array.isArray(await runtime.listMcpServerStatuses())).toBe(true);
  74. });
  75. });
  76. describe('MCP 配置热生效', () => {
  77. it('保存 → 出现在 config 与列表 → 删除后消失,全程不重启', async () => {
  78. const service = new McpService(runtime);
  79. const before = await service.list();
  80. const saved = await service.save({
  81. id: 'smoke-stdio',
  82. transport: 'stdio',
  83. command: 'node',
  84. args: ['-e', 'process.exit(0)'],
  85. enabled: false,
  86. });
  87. const entry = saved.find((item) => item.id === 'smoke-stdio');
  88. expect(entry).toMatchObject({ transport: 'stdio', command: 'node', enabled: false });
  89. expect(saved.length).toBe(before.length + 1);
  90. const config = await runtime.readConfig();
  91. const servers = config.mcp_servers as Record<string, Record<string, unknown>>;
  92. expect(servers['smoke-stdio']).toMatchObject({ command: 'node', enabled: false });
  93. const afterRemove = await service.remove('smoke-stdio');
  94. expect(afterRemove.find((item) => item.id === 'smoke-stdio')).toBeUndefined();
  95. expect(runtime.status.state).toBe('ready');
  96. });
  97. it('http 传输写入 url 与 bearer_token_env_var', async () => {
  98. const service = new McpService(runtime);
  99. await service.save({
  100. id: 'smoke-http',
  101. transport: 'http',
  102. url: 'http://127.0.0.1:9999/mcp',
  103. enabled: false,
  104. bearerTokenEnvVar: 'ZSJZ_SMOKE_TOKEN',
  105. });
  106. const config = await runtime.readConfig();
  107. const servers = config.mcp_servers as Record<string, Record<string, unknown>>;
  108. expect(servers['smoke-http']).toMatchObject({
  109. url: 'http://127.0.0.1:9999/mcp',
  110. bearer_token_env_var: 'ZSJZ_SMOKE_TOKEN',
  111. });
  112. await service.remove('smoke-http');
  113. });
  114. });
  115. describe('Skill 安装能被 Codex 识别', () => {
  116. it('装目录 → skills/list 可见 → 启停 → 卸载', async () => {
  117. const service = new SkillService(runtime);
  118. const source = join(tmpRoot, 'skill-src');
  119. await mkdir(join(source, 'references'), { recursive: true });
  120. await writeFile(join(source, 'SKILL.md'), SKILL_MD, 'utf8');
  121. await writeFile(join(source, 'references', 'note.md'), 'note', 'utf8');
  122. await service.installFromFolder(source, { overwrite: true });
  123. const installedDir = join(getSkillsDir(), 'smoke-skill');
  124. expect((await stat(join(installedDir, 'SKILL.md'))).isFile()).toBe(true);
  125. // 关键断言:我们自己写的目录布局确实被 Codex 认出来
  126. const listed = await service.list({ forceReload: true });
  127. const installed = listed.find((item) => item.name === 'smoke-skill');
  128. expect(installed).toBeTruthy();
  129. expect(installed?.managed).toBe(true);
  130. expect(installed?.description).toBe('冒烟测试用的技能');
  131. expect(await service.setEnabled({ name: 'smoke-skill' }, false)).toBe(false);
  132. const disabled = await service.list({ forceReload: true });
  133. expect(disabled.find((item) => item.name === 'smoke-skill')?.enabled).toBe(false);
  134. expect(await service.setEnabled({ name: 'smoke-skill' }, true)).toBe(true);
  135. const read = await service.read({ name: 'smoke-skill' });
  136. expect(read.content).toContain('smoke-skill');
  137. expect(read.files.map((file) => file.path).sort()).toEqual(['SKILL.md', 'references/note.md']);
  138. await service.remove({ name: 'smoke-skill' });
  139. expect(await stat(installedDir).catch(() => null)).toBeNull();
  140. const afterRemove = await service.list({ forceReload: true });
  141. expect(afterRemove.find((item) => item.name === 'smoke-skill')).toBeUndefined();
  142. });
  143. it('删除后 config 里的残留条目要真的被清掉', async () => {
  144. const service = new SkillService(runtime);
  145. const source = join(tmpRoot, 'stale-skill');
  146. await mkdir(source, { recursive: true });
  147. await writeFile(join(source, 'SKILL.md'), SKILL_MD.replace('smoke-skill', 'stale-skill'), 'utf8');
  148. await service.installFromFolder(source, { overwrite: true });
  149. await service.setEnabled({ name: 'stale-skill' }, false);
  150. const before = await runtime.readConfig();
  151. expect(JSON.stringify(before)).toContain('stale-skill');
  152. await service.remove({ name: 'stale-skill' });
  153. const after = await runtime.readConfig();
  154. expect(JSON.stringify(after)).not.toContain('stale-skill');
  155. }, 120_000);
  156. it('内置 skill 被标记为不可管理', async () => {
  157. const service = new SkillService(runtime);
  158. const items = await service.list();
  159. const builtin = items.find((item) => item.scope === 'system');
  160. expect(builtin?.managed).toBe(false);
  161. if (builtin) {
  162. await expect(service.remove({ path: builtin.path })).rejects.toThrow(/内置或暂存目录/);
  163. }
  164. });
  165. });
  166. describe('切换 provider 会重启子进程', () => {
  167. it('applyProvider 后 config 更新且磁盘上没有密钥', async () => {
  168. responsesStatus = 401;
  169. chatStatus = 401;
  170. const applied = await applyProvider(runtime, {
  171. modelId: 'smoke-model-2',
  172. name: '冒烟端点 2',
  173. baseUrl,
  174. apiKey: API_KEY,
  175. providerType: 'OPENAI',
  176. modelRecordId: 42,
  177. });
  178. expect(applied).toMatchObject({ model: 'smoke-model-2', providerId: 'zsjz', modelRecordId: '42' });
  179. expect(runtime.status.state).toBe('ready');
  180. const config = await runtime.readConfig();
  181. expect(config.model_provider).toBe('zsjz');
  182. expect(config.model).toBe('smoke-model-2');
  183. expect(JSON.stringify(config)).not.toContain(API_KEY);
  184. const persisted = await readAppliedProvider();
  185. expect(persisted?.model).toBe('smoke-model-2');
  186. expect(JSON.stringify(persisted)).not.toContain(API_KEY);
  187. });
  188. it('chat-only 端点经内置桥接应用成功,落盘存上游真实地址', async () => {
  189. responsesStatus = 404;
  190. chatStatus = 200;
  191. const applied = await applyProvider(runtime, {
  192. modelId: 'smoke-chat-model',
  193. baseUrl,
  194. apiKey: API_KEY,
  195. providerType: 'VLLM',
  196. });
  197. expect(applied.bridged).toBe(true);
  198. // 落盘存上游真实地址,不是桥接的本地 URL
  199. expect(applied.baseUrl).toBe(baseUrl);
  200. const config = await runtime.readConfig();
  201. expect(config.model_provider).toBe('zsjz');
  202. const entry = (config.model_providers as Record<string, Record<string, unknown>> | undefined)?.zsjz;
  203. expect(entry?.base_url).not.toBe(baseUrl);
  204. expect(entry?.base_url).toMatch(/127\.0\.0\.1/);
  205. expect(entry?.wire_api).toBe('responses');
  206. });
  207. it('双协议都不存在的端点拒绝应用', async () => {
  208. responsesStatus = 404;
  209. chatStatus = 404;
  210. await expect(
  211. applyProvider(runtime, { modelId: 'x', baseUrl, apiKey: API_KEY, providerType: 'OPENAI' }),
  212. ).rejects.toThrow(/无法对接/);
  213. });
  214. it('clearProvider 回到无 provider 状态', async () => {
  215. await clearProvider(runtime);
  216. expect(await readAppliedProvider()).toBeNull();
  217. expect(runtime.provider).toBeNull();
  218. responsesStatus = 401;
  219. chatStatus = 401;
  220. });
  221. });
  222. describe('停止与回收', () => {
  223. it('stop 后状态归零,未经懒启动的调用直接失败', async () => {
  224. await runtime.stop();
  225. expect(runtime.status.state).toBe('stopped');
  226. await expect(runtime.interruptTurn('thread-x', 'turn-x')).rejects.toThrow(/未运行/);
  227. });
  228. it('诊断信息会屏蔽密钥形态的字符串', async () => {
  229. const { sanitizeDiagnostic } = await import('./codexRuntime');
  230. expect(sanitizeDiagnostic('token sk-abcdefghij1234567890 leaked')).not.toContain('sk-abcdefghij1234567890');
  231. expect(sanitizeDiagnostic('Authorization: Bearer abc.def-ghi')).toContain('[redacted]');
  232. });
  233. });