codexRuntime.itest.ts 11 KB

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