codexRuntime.ts 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814
  1. import { EventEmitter } from 'node:events';
  2. import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
  3. import { locateCodexBinary, readCodexVersion } from './codexLocator';
  4. import { JsonRpcPeer, type JsonRpcServerRequest } from './jsonRpcPeer';
  5. /**
  6. * 移植自 Noobi.ai src/main/codexAppServer.ts,主要改动:
  7. * 1. 删掉全部账号相关能力(account/read、account/login/start、account/logout)——本模块不做任何登录;
  8. * 2. 不再传 --strict-config(用户与程序共写 config.toml,未知字段只应告警不该退出);
  9. * 3. 新增 provider 注入:用 -c 覆盖 model_provider / model / model_providers.*,api_key 只走子进程环境变量;
  10. * 4. clientInfo 改为本项目标识。
  11. */
  12. export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
  13. export interface ModelOption {
  14. id: string;
  15. model: string;
  16. displayName: string;
  17. description: string;
  18. isDefault: boolean;
  19. defaultEffort: string;
  20. efforts: string[];
  21. }
  22. export interface RuntimeCapabilities {
  23. namespaceTools: boolean;
  24. imageGeneration: boolean;
  25. webSearch: boolean;
  26. }
  27. export interface RuntimeStatus {
  28. state: 'stopped' | 'starting' | 'ready' | 'error';
  29. binaryPath: string | null;
  30. version: string | null;
  31. codexHome: string | null;
  32. models: ModelOption[];
  33. capabilities: RuntimeCapabilities;
  34. providerId: string | null;
  35. defaultModel: string | null;
  36. /** model/list 拿不到可用模型时为 true */
  37. degraded: boolean;
  38. error: string | null;
  39. }
  40. export interface CodexMcpServerStatus {
  41. name: string;
  42. authStatus: string;
  43. connected: boolean;
  44. toolCount: number;
  45. tools: Array<{ name: string; description: string }>;
  46. }
  47. export interface CodexSkillSummary {
  48. name: string;
  49. description: string;
  50. path: string;
  51. scope: 'user' | 'repo' | 'system' | 'admin' | string;
  52. enabled: boolean;
  53. cwd: string;
  54. }
  55. /** 一次 spawn 用的模型来源。id 为 null 表示使用 Codex 内置 provider(ollama / lmstudio) */
  56. export interface CodexProviderSpec {
  57. id: string | null;
  58. name?: string | null;
  59. baseUrl?: string | null;
  60. model: string;
  61. /** 内置 provider id,例如 'ollama';给了就不再写 model_providers 段 */
  62. builtinProvider?: string | null;
  63. /** apiKey 只注入子进程环境变量,不落任何盘 */
  64. apiKey?: string | null;
  65. envKey?: string;
  66. httpHeaders?: Record<string, string> | null;
  67. envHttpHeaders?: Record<string, string> | null;
  68. /** 仅放行 request_max_retries / stream_max_retries / stream_idle_timeout_ms / query_params */
  69. extra?: Record<string, JsonValue> | null;
  70. }
  71. export interface CodexRuntimeOptions {
  72. codexHome: string;
  73. provider?: CodexProviderSpec | null;
  74. clientInfo?: { name: string; title: string; version: string };
  75. }
  76. export interface StartThreadOptions {
  77. cwd: string;
  78. model?: string | null;
  79. sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access';
  80. approvalPolicy?: 'untrusted' | 'on-request' | 'never';
  81. developerInstructions?: string;
  82. ephemeral?: boolean;
  83. }
  84. export interface StartTurnOptions {
  85. threadId: string;
  86. prompt: string;
  87. cwd?: string;
  88. model?: string | null;
  89. effort?: string | null;
  90. approvalPolicy?: 'untrusted' | 'on-request' | 'never';
  91. skills?: Array<{ name: string; path: string }>;
  92. /** 仅宿主侧的完成期限,不会发给 App Server */
  93. timeoutMs?: number;
  94. }
  95. export interface TurnResult {
  96. turnId: string;
  97. status: string;
  98. text: string;
  99. raw: unknown;
  100. }
  101. interface SkillsListResponse {
  102. data: Array<{
  103. cwd: string;
  104. skills: Array<{
  105. name: string;
  106. description: string;
  107. path: string;
  108. scope: string;
  109. enabled: boolean;
  110. }>;
  111. }>;
  112. }
  113. interface ListMcpServerStatusResponse {
  114. data: Array<{
  115. name: string;
  116. serverInfo: unknown | null;
  117. tools: Record<string, unknown>;
  118. authStatus: string;
  119. }>;
  120. nextCursor: string | null;
  121. }
  122. interface InitializeResponse {
  123. userAgent: string;
  124. codexHome: string;
  125. platformFamily: string;
  126. platformOs: string;
  127. }
  128. interface ModelListResponse {
  129. data: Array<{
  130. id: string;
  131. model: string;
  132. displayName: string;
  133. description: string;
  134. isDefault: boolean;
  135. defaultReasoningEffort: string;
  136. supportedReasoningEfforts: Array<{ reasoningEffort: string }>;
  137. }>;
  138. nextCursor: string | null;
  139. }
  140. interface ModelProviderCapabilitiesResponse {
  141. namespaceTools: boolean;
  142. imageGeneration: boolean;
  143. webSearch: boolean;
  144. }
  145. interface ThreadResponse {
  146. thread: { id: string };
  147. model: string;
  148. }
  149. interface TurnStartResponse {
  150. turn: { id: string; status: string };
  151. }
  152. interface EarlyTurnState {
  153. text: string;
  154. completed: TurnResult | null;
  155. }
  156. interface TurnWaiter {
  157. text: string;
  158. resolve(result: TurnResult): void;
  159. reject(error: Error): void;
  160. timer: NodeJS.Timeout;
  161. }
  162. const TURN_TIMEOUT_MS = 20 * 60 * 1_000;
  163. export const DEFAULT_API_KEY_ENV = 'ZSJZ_CODEX_API_KEY';
  164. const PROVIDER_EXTRA_ALLOWLIST = new Set([
  165. 'request_max_retries',
  166. 'stream_max_retries',
  167. 'stream_idle_timeout_ms',
  168. 'query_params',
  169. ]);
  170. export class CodexRuntime extends EventEmitter {
  171. readonly #codexHome: string;
  172. readonly #clientInfo: { name: string; title: string; version: string };
  173. #provider: CodexProviderSpec | null;
  174. #child: ChildProcessWithoutNullStreams | null = null;
  175. #peer: JsonRpcPeer | null = null;
  176. #startPromise: Promise<RuntimeStatus> | null = null;
  177. #runtime: RuntimeStatus = emptyRuntimeStatus();
  178. #turnWaiters = new Map<string, TurnWaiter>();
  179. #earlyTurnStates = new Map<string, EarlyTurnState>();
  180. #runTurnStartsInFlight = 0;
  181. #generation = 0;
  182. constructor(options: CodexRuntimeOptions) {
  183. super();
  184. this.#codexHome = options.codexHome;
  185. this.#provider = options.provider ?? null;
  186. this.#clientInfo = options.clientInfo ?? {
  187. name: 'zsjz_ai',
  188. title: '清鉴线索调查工具',
  189. version: '0.1.0',
  190. };
  191. }
  192. get status(): RuntimeStatus {
  193. return structuredClone(this.#runtime);
  194. }
  195. get provider(): CodexProviderSpec | null {
  196. return this.#provider ? { ...this.#provider, apiKey: null } : null;
  197. }
  198. /** 换 provider 必须重启子进程:api_key 走的是子进程环境变量,无法热更新 */
  199. async applyProvider(provider: CodexProviderSpec | null): Promise<RuntimeStatus> {
  200. this.#provider = provider;
  201. if (this.#child) await this.stop();
  202. return this.start();
  203. }
  204. async start(): Promise<RuntimeStatus> {
  205. if (this.#runtime.state === 'ready' && this.#peer) return this.status;
  206. if (this.#startPromise) return this.#startPromise;
  207. const generation = ++this.#generation;
  208. this.#startPromise = this.#start(generation);
  209. try {
  210. return await this.#startPromise;
  211. } finally {
  212. this.#startPromise = null;
  213. }
  214. }
  215. async refresh(): Promise<RuntimeStatus> {
  216. await this.start();
  217. let models: ModelOption[] = [];
  218. let degraded = false;
  219. try {
  220. models = await this.listModels();
  221. } catch (error) {
  222. // 第三方/本地端点常常不实现 model/list,这里降级而不是失败
  223. degraded = true;
  224. this.emit('diagnostic', `model/list 不可用:${asError(error).message}`);
  225. }
  226. const capabilities = await this.readModelProviderCapabilities().catch(() => emptyCapabilities());
  227. this.#runtime = {
  228. ...this.#runtime,
  229. models,
  230. capabilities,
  231. degraded: degraded || models.length === 0,
  232. // model/list 返回的是 Codex 内置模型目录(与自定义 provider 无关),
  233. // 实际会用的是 provider 里配置的模型,所以它优先
  234. defaultModel: this.#provider?.model ?? models.find((item) => item.isDefault)?.model ?? null,
  235. state: 'ready',
  236. error: null,
  237. };
  238. this.emit('status', this.status);
  239. return this.status;
  240. }
  241. async readModelProviderCapabilities(): Promise<RuntimeCapabilities> {
  242. const result = await this.#request<ModelProviderCapabilitiesResponse>(
  243. 'modelProvider/capabilities/read',
  244. {},
  245. );
  246. return {
  247. namespaceTools: result.namespaceTools === true,
  248. imageGeneration: result.imageGeneration === true,
  249. webSearch: result.webSearch === true,
  250. };
  251. }
  252. async listModels(): Promise<ModelOption[]> {
  253. const models: ModelOption[] = [];
  254. let cursor: string | null = null;
  255. for (let page = 0; page < 10; page += 1) {
  256. const result: ModelListResponse = await this.#request<ModelListResponse>('model/list', {
  257. cursor,
  258. limit: 100,
  259. includeHidden: false,
  260. });
  261. models.push(
  262. ...result.data.map((model) => ({
  263. id: model.id,
  264. model: model.model,
  265. displayName: model.displayName,
  266. description: model.description,
  267. isDefault: model.isDefault,
  268. defaultEffort: model.defaultReasoningEffort,
  269. efforts: model.supportedReasoningEfforts.map((item) => item.reasoningEffort),
  270. })),
  271. );
  272. cursor = result.nextCursor;
  273. if (!cursor) break;
  274. }
  275. return models;
  276. }
  277. async listSkills(options: { cwds?: string[]; forceReload?: boolean } = {}): Promise<CodexSkillSummary[]> {
  278. await this.start();
  279. const result = await this.#request<SkillsListResponse>('skills/list', {
  280. ...(options.cwds?.length ? { cwds: options.cwds } : {}),
  281. forceReload: options.forceReload ?? false,
  282. });
  283. return result.data.flatMap((entry) =>
  284. entry.skills.map((skill) => ({
  285. name: skill.name,
  286. description: skill.description,
  287. path: skill.path,
  288. scope: skill.scope,
  289. enabled: skill.enabled,
  290. cwd: entry.cwd,
  291. })),
  292. );
  293. }
  294. async setSkillEnabled(selector: { path?: string; name?: string }, enabled: boolean): Promise<boolean> {
  295. await this.start();
  296. const result = await this.#request<{ effectiveEnabled: boolean }>('skills/config/write', {
  297. ...(selector.path ? { path: selector.path } : {}),
  298. ...(!selector.path && selector.name ? { name: selector.name } : {}),
  299. enabled,
  300. });
  301. return result.effectiveEnabled;
  302. }
  303. async listMcpServerStatuses(): Promise<CodexMcpServerStatus[]> {
  304. await this.start();
  305. const statuses: CodexMcpServerStatus[] = [];
  306. let cursor: string | null = null;
  307. for (let page = 0; page < 20; page += 1) {
  308. const result: ListMcpServerStatusResponse = await this.#request<ListMcpServerStatusResponse>(
  309. 'mcpServerStatus/list',
  310. {
  311. cursor,
  312. limit: 100,
  313. detail: 'toolsAndAuthOnly',
  314. },
  315. );
  316. statuses.push(
  317. ...result.data.map((server) => ({
  318. name: server.name,
  319. authStatus: server.authStatus,
  320. connected: server.serverInfo !== null,
  321. toolCount: Object.keys(server.tools ?? {}).length,
  322. tools: toToolSummaries(server.tools),
  323. })),
  324. );
  325. cursor = result.nextCursor;
  326. if (!cursor) break;
  327. }
  328. return statuses;
  329. }
  330. async readConfig(): Promise<Record<string, unknown>> {
  331. await this.start();
  332. const result = await this.#request<{ config: Record<string, unknown> }>('config/read', {
  333. includeLayers: false,
  334. });
  335. return structuredClone(result.config);
  336. }
  337. /** 局部写:Codex 自己也持有 config.toml 写权,禁止整文件覆盖 */
  338. async writeConfigValue(keyPath: string, value: JsonValue): Promise<void> {
  339. await this.start();
  340. await this.#request<unknown>('config/value/write', { keyPath, value, mergeStrategy: 'replace' });
  341. }
  342. async reloadMcpServers(): Promise<void> {
  343. await this.start();
  344. await this.#request<unknown>('config/mcpServer/reload');
  345. }
  346. async startThread(options: StartThreadOptions): Promise<string> {
  347. await this.start();
  348. const result = await this.#request<ThreadResponse>('thread/start', {
  349. cwd: options.cwd,
  350. ...(options.model ? { model: options.model } : {}),
  351. sandbox: options.sandbox ?? 'workspace-write',
  352. approvalPolicy: options.approvalPolicy ?? 'never',
  353. ...(options.developerInstructions ? { developerInstructions: options.developerInstructions } : {}),
  354. ...(options.ephemeral === undefined ? {} : { ephemeral: options.ephemeral }),
  355. experimentalRawEvents: false,
  356. });
  357. return result.thread.id;
  358. }
  359. async resumeThread(threadId: string, options: StartThreadOptions): Promise<string> {
  360. await this.start();
  361. const result = await this.#request<ThreadResponse>('thread/resume', {
  362. threadId,
  363. cwd: options.cwd,
  364. ...(options.model ? { model: options.model } : {}),
  365. sandbox: options.sandbox ?? 'workspace-write',
  366. approvalPolicy: options.approvalPolicy ?? 'never',
  367. ...(options.developerInstructions ? { developerInstructions: options.developerInstructions } : {}),
  368. excludeTurns: true,
  369. });
  370. return result.thread.id;
  371. }
  372. async startTurn(options: StartTurnOptions): Promise<string> {
  373. await this.start();
  374. const result = await this.#request<TurnStartResponse>('turn/start', {
  375. threadId: options.threadId,
  376. input: [
  377. { type: 'text', text: options.prompt, text_elements: [] },
  378. ...(options.skills ?? []).map((skill) => ({
  379. type: 'skill' as const,
  380. name: skill.name,
  381. path: skill.path,
  382. })),
  383. ],
  384. ...(options.cwd ? { cwd: options.cwd } : {}),
  385. ...(options.model ? { model: options.model } : {}),
  386. ...(options.effort ? { effort: options.effort } : {}),
  387. ...(options.approvalPolicy ? { approvalPolicy: options.approvalPolicy } : {}),
  388. });
  389. return result.turn.id;
  390. }
  391. async runTurn(options: StartTurnOptions): Promise<TurnResult> {
  392. this.#runTurnStartsInFlight += 1;
  393. let turnId: string;
  394. try {
  395. turnId = await this.startTurn(options);
  396. } catch (error) {
  397. this.#releaseRunTurnStart();
  398. throw error;
  399. }
  400. // turn/completed 可能早于 turn/start 的响应回来,先存起来
  401. const early = this.#earlyTurnStates.get(turnId);
  402. this.#earlyTurnStates.delete(turnId);
  403. if (early?.completed) {
  404. this.#releaseRunTurnStart();
  405. return early.completed;
  406. }
  407. return new Promise<TurnResult>((resolve, reject) => {
  408. const timeoutMs =
  409. options.timeoutMs === undefined
  410. ? TURN_TIMEOUT_MS
  411. : Math.min(TURN_TIMEOUT_MS, Math.max(1_000, Math.trunc(options.timeoutMs)));
  412. const timer = setTimeout(() => {
  413. this.#turnWaiters.delete(turnId);
  414. void this.interruptTurn(options.threadId, turnId).catch(() => undefined);
  415. reject(new Error(`Codex turn ${turnId} 超时`));
  416. }, timeoutMs);
  417. timer.unref();
  418. this.#turnWaiters.set(turnId, { text: early?.text ?? '', resolve, reject, timer });
  419. this.#releaseRunTurnStart();
  420. });
  421. }
  422. async interruptTurn(threadId: string, turnId: string): Promise<void> {
  423. await this.#request<Record<string, never>>('turn/interrupt', { threadId, turnId });
  424. }
  425. async unsubscribeThread(threadId: string): Promise<void> {
  426. await this.#request<{ status: string }>('thread/unsubscribe', { threadId });
  427. }
  428. respondToServerRequest(requestId: string | number, result: unknown): void {
  429. if (!this.#peer) throw new Error('Codex App Server 未运行');
  430. this.#peer.respond(requestId, result);
  431. }
  432. rejectServerRequest(requestId: string | number, code: number, message: string): void {
  433. if (!this.#peer) throw new Error('Codex App Server 未运行');
  434. this.#peer.respondError(requestId, { code, message });
  435. }
  436. async stop(): Promise<void> {
  437. this.#generation += 1;
  438. const child = this.#child;
  439. const peer = this.#peer;
  440. this.#child = null;
  441. this.#peer = null;
  442. for (const waiter of this.#turnWaiters.values()) {
  443. clearTimeout(waiter.timer);
  444. waiter.reject(new Error('Codex App Server 在本轮对话进行中被停止'));
  445. }
  446. this.#turnWaiters.clear();
  447. this.#earlyTurnStates.clear();
  448. this.#runTurnStartsInFlight = 0;
  449. if (child && child.exitCode === null) {
  450. peer?.endOutput();
  451. await Promise.race([
  452. new Promise<void>((resolve) => child.once('exit', () => resolve())),
  453. new Promise<void>((resolve) => setTimeout(resolve, 1_500)),
  454. ]);
  455. if (child.exitCode === null) {
  456. child.kill('SIGTERM');
  457. await Promise.race([
  458. new Promise<void>((resolve) => child.once('exit', () => resolve())),
  459. new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
  460. ]);
  461. }
  462. if (child.exitCode === null) child.kill('SIGKILL');
  463. }
  464. peer?.close(new Error('Codex App Server 已停止'));
  465. this.#runtime = { ...emptyRuntimeStatus(), providerId: this.#provider?.id ?? null };
  466. this.emit('status', this.status);
  467. }
  468. async dispose(): Promise<void> {
  469. await this.stop();
  470. this.removeAllListeners();
  471. }
  472. async #start(generation: number): Promise<RuntimeStatus> {
  473. this.#runtime = { ...emptyRuntimeStatus(), state: 'starting', providerId: this.#provider?.id ?? null };
  474. this.emit('status', this.status);
  475. let child: ChildProcessWithoutNullStreams | null = null;
  476. let peer: JsonRpcPeer | null = null;
  477. try {
  478. const binaryPath = await locateCodexBinary();
  479. this.#assertGeneration(generation);
  480. const version = readCodexVersion(binaryPath);
  481. this.#assertGeneration(generation);
  482. const provider = this.#provider;
  483. child = spawn(binaryPath, buildArgs(provider), {
  484. cwd: process.cwd(),
  485. env: buildEnv(this.#codexHome, provider),
  486. stdio: ['pipe', 'pipe', 'pipe'],
  487. windowsHide: true,
  488. });
  489. this.#child = child;
  490. peer = new JsonRpcPeer(child.stdout, child.stdin);
  491. this.#peer = peer;
  492. peer.on('notification', (notification) => this.#handleNotification(notification));
  493. peer.on('serverRequest', (request: JsonRpcServerRequest) => this.emit('serverRequest', request));
  494. peer.on('protocolError', (error) => this.emit('diagnostic', asError(error).message));
  495. peer.once('closed', (error) => {
  496. if (this.#peer !== peer || this.#child !== child) return;
  497. if (child?.exitCode === null) child.kill('SIGTERM');
  498. this.#handleExit(child!, asError(error));
  499. });
  500. peer.start();
  501. child.stderr.setEncoding('utf8');
  502. child.stderr.on('data', (chunk: string) => {
  503. for (const line of chunk.split(/\r?\n/u).filter(Boolean)) {
  504. this.emit('diagnostic', sanitizeDiagnostic(line));
  505. }
  506. });
  507. child.once('error', (error) => this.#handleExit(child!, error));
  508. child.once('exit', (code, signal) => {
  509. this.#handleExit(child!, new Error(`Codex App Server 退出(${code ?? signal ?? 'unknown'})`));
  510. });
  511. const initialized = await peer.request<InitializeResponse>('initialize', {
  512. clientInfo: this.#clientInfo,
  513. capabilities: { experimentalApi: true },
  514. });
  515. this.#assertGeneration(generation);
  516. peer.notify('initialized');
  517. this.#runtime = {
  518. ...emptyRuntimeStatus(),
  519. state: 'ready',
  520. binaryPath,
  521. version,
  522. codexHome: initialized.codexHome,
  523. providerId: provider?.id ?? provider?.builtinProvider ?? null,
  524. };
  525. return await this.refresh();
  526. } catch (error) {
  527. const message = asError(error).message;
  528. peer?.close(asError(error));
  529. if (this.#peer === peer) this.#peer = null;
  530. if (this.#child === child) this.#child = null;
  531. if (child && child.exitCode === null) child.kill('SIGTERM');
  532. if (generation === this.#generation) {
  533. this.#runtime = { ...this.#runtime, state: 'error', error: sanitizeDiagnostic(message) };
  534. this.emit('status', this.status);
  535. }
  536. throw error;
  537. }
  538. }
  539. #request<T>(method: string, params?: unknown): Promise<T> {
  540. if (!this.#peer) throw new Error('Codex App Server 未运行');
  541. return this.#peer.request<T>(method, params);
  542. }
  543. #handleNotification(notification: { method: string; params?: unknown }): void {
  544. const params = asRecord(notification.params);
  545. const turnId = readString(params?.turnId) ?? readString(asRecord(params?.turn)?.id);
  546. if (turnId) {
  547. const waiter = this.#turnWaiters.get(turnId);
  548. if (waiter && notification.method === 'item/agentMessage/delta') {
  549. waiter.text += readString(params?.delta) ?? '';
  550. }
  551. if (waiter && notification.method === 'item/completed') {
  552. const item = asRecord(params?.item);
  553. if (item?.type === 'agentMessage' && typeof item.text === 'string') waiter.text = item.text;
  554. }
  555. if (waiter && notification.method === 'turn/completed') {
  556. clearTimeout(waiter.timer);
  557. this.#turnWaiters.delete(turnId);
  558. const turn = asRecord(params?.turn);
  559. waiter.resolve({
  560. turnId,
  561. status: readString(turn?.status) ?? 'completed',
  562. text: waiter.text,
  563. raw: params,
  564. });
  565. }
  566. if (!waiter && this.#runTurnStartsInFlight > 0) {
  567. const early = this.#earlyTurnStates.get(turnId) ?? { text: '', completed: null };
  568. if (notification.method === 'item/agentMessage/delta') {
  569. early.text += readString(params?.delta) ?? '';
  570. }
  571. if (notification.method === 'item/completed') {
  572. const item = asRecord(params?.item);
  573. if (item?.type === 'agentMessage' && typeof item.text === 'string') early.text = item.text;
  574. }
  575. if (notification.method === 'turn/completed') {
  576. const turn = asRecord(params?.turn);
  577. early.completed = {
  578. turnId,
  579. status: readString(turn?.status) ?? 'completed',
  580. text: early.text,
  581. raw: params,
  582. };
  583. }
  584. this.#earlyTurnStates.set(turnId, early);
  585. }
  586. }
  587. this.emit('notification', notification);
  588. }
  589. #handleExit(child: ChildProcessWithoutNullStreams, error: Error): void {
  590. if (this.#child !== child) return;
  591. this.#peer?.close(error);
  592. this.#peer = null;
  593. this.#child = null;
  594. this.#runtime = {
  595. ...this.#runtime,
  596. state: 'error',
  597. degraded: true,
  598. error: sanitizeDiagnostic(error.message),
  599. };
  600. for (const waiter of this.#turnWaiters.values()) {
  601. clearTimeout(waiter.timer);
  602. waiter.reject(error);
  603. }
  604. this.#turnWaiters.clear();
  605. this.#earlyTurnStates.clear();
  606. this.#runTurnStartsInFlight = 0;
  607. this.emit('status', this.status);
  608. }
  609. #assertGeneration(generation: number): void {
  610. if (generation !== this.#generation) throw new Error('Codex App Server 的启动已被取消');
  611. }
  612. #releaseRunTurnStart(): void {
  613. this.#runTurnStartsInFlight = Math.max(0, this.#runTurnStartsInFlight - 1);
  614. if (this.#runTurnStartsInFlight === 0) this.#earlyTurnStates.clear();
  615. }
  616. }
  617. /** 每个配置项单独一个 -c,避免手写嵌套 inline table */
  618. export function buildArgs(provider: CodexProviderSpec | null): string[] {
  619. const args = ['app-server', '--listen', 'stdio://'];
  620. if (!provider) return args;
  621. const providerKey = provider.builtinProvider ?? provider.id;
  622. if (!providerKey) return args;
  623. args.push('-c', `model_provider=${tomlString(providerKey)}`);
  624. args.push('-c', `model=${tomlString(provider.model)}`);
  625. if (provider.builtinProvider) {
  626. // 内置 provider 只允许覆盖 base_url,其余字段由 Codex 自己决定;
  627. // 不覆盖就会打到 localhost:11434,远端 Ollama / LM Studio 连不上
  628. if (provider.baseUrl) {
  629. args.push('-c', `model_providers.${provider.builtinProvider}.base_url=${tomlString(provider.baseUrl)}`);
  630. }
  631. return args;
  632. }
  633. const prefix = `model_providers.${provider.id}`;
  634. if (provider.name) args.push('-c', `${prefix}.name=${tomlString(provider.name)}`);
  635. if (provider.baseUrl) args.push('-c', `${prefix}.base_url=${tomlString(provider.baseUrl)}`);
  636. // wire_api 只支持 responses:0.155.1 已下线 chat
  637. args.push('-c', `${prefix}.wire_api="responses"`);
  638. args.push('-c', `${prefix}.requires_openai_auth=false`);
  639. if (provider.apiKey) {
  640. args.push('-c', `${prefix}.env_key=${tomlString(provider.envKey ?? DEFAULT_API_KEY_ENV)}`);
  641. }
  642. if (provider.httpHeaders && Object.keys(provider.httpHeaders).length) {
  643. args.push('-c', `${prefix}.http_headers=${tomlInlineTable(provider.httpHeaders)}`);
  644. }
  645. if (provider.envHttpHeaders && Object.keys(provider.envHttpHeaders).length) {
  646. args.push('-c', `${prefix}.env_http_headers=${tomlInlineTable(provider.envHttpHeaders)}`);
  647. }
  648. for (const [key, value] of Object.entries(provider.extra ?? {})) {
  649. if (!PROVIDER_EXTRA_ALLOWLIST.has(key) || value === null || value === undefined) continue;
  650. args.push('-c', `${prefix}.${key}=${tomlValue(value)}`);
  651. }
  652. return args;
  653. }
  654. function buildEnv(codexHome: string, provider: CodexProviderSpec | null): NodeJS.ProcessEnv {
  655. const env: NodeJS.ProcessEnv = {
  656. ...process.env,
  657. CODEX_HOME: codexHome,
  658. RUST_LOG: process.env.RUST_LOG ?? 'warn',
  659. LOG_FORMAT: 'json',
  660. };
  661. // 密钥只存在于子进程环境变量,config.toml 里只写 env_key 名字
  662. if (provider?.apiKey && !provider.builtinProvider) {
  663. env[provider.envKey ?? DEFAULT_API_KEY_ENV] = provider.apiKey;
  664. }
  665. return env;
  666. }
  667. function tomlString(value: string): string {
  668. return JSON.stringify(value);
  669. }
  670. function tomlInlineTable(value: Record<string, string>): string {
  671. const entries = Object.entries(value).map(([key, item]) => `${tomlKey(key)}=${tomlString(item)}`);
  672. return `{${entries.join(',')}}`;
  673. }
  674. function tomlKey(key: string): string {
  675. return /^[A-Za-z0-9_-]+$/.test(key) ? key : tomlString(key);
  676. }
  677. function tomlValue(value: JsonValue): string {
  678. if (typeof value === 'string') return tomlString(value);
  679. if (typeof value === 'number' || typeof value === 'boolean') return String(value);
  680. if (Array.isArray(value)) {
  681. return `[${value.filter((item) => item !== null).map(tomlValue).join(',')}]`;
  682. }
  683. if (value && typeof value === 'object') {
  684. // TOML inline table 用 `=` 而不是 JSON 的 `:`,不能直接 JSON.stringify
  685. return tomlInlineTable(
  686. Object.fromEntries(
  687. Object.entries(value as Record<string, JsonValue>)
  688. .filter(([, item]) => typeof item === 'string')
  689. .map(([key, item]) => [key, String(item)]),
  690. ),
  691. );
  692. }
  693. return '""';
  694. }
  695. function emptyRuntimeStatus(): RuntimeStatus {
  696. return {
  697. state: 'stopped',
  698. binaryPath: null,
  699. version: null,
  700. codexHome: null,
  701. models: [],
  702. capabilities: emptyCapabilities(),
  703. providerId: null,
  704. defaultModel: null,
  705. degraded: false,
  706. error: null,
  707. };
  708. }
  709. function emptyCapabilities(): RuntimeCapabilities {
  710. return { namespaceTools: false, imageGeneration: false, webSearch: false };
  711. }
  712. function asRecord(value: unknown): Record<string, unknown> | null {
  713. return value && typeof value === 'object' && !Array.isArray(value)
  714. ? (value as Record<string, unknown>)
  715. : null;
  716. }
  717. function readString(value: unknown): string | null {
  718. return typeof value === 'string' ? value : null;
  719. }
  720. /**
  721. * mcpServerStatus/list 的 tools 是「工具名 → 详情」映射,详情结构由 Codex 版本决定。
  722. * 这里只认 name / description,认不出的形态退化成只留 key 名,避免协议一变列表就空掉。
  723. */
  724. export function toToolSummaries(
  725. tools: Record<string, unknown> | null | undefined,
  726. ): Array<{ name: string; description: string }> {
  727. return Object.entries(tools ?? {}).map(([key, value]) => {
  728. const detail = asRecord(value);
  729. return {
  730. name: readString(detail?.name) || key,
  731. description: readString(detail?.description) || '',
  732. };
  733. });
  734. }
  735. function asError(value: unknown): Error {
  736. return value instanceof Error ? value : new Error(String(value));
  737. }
  738. export function sanitizeDiagnostic(value: string): string {
  739. return value
  740. .replace(/sk-[A-Za-z0-9_-]{12,}/gu, 'sk-[redacted]')
  741. .replace(/Bearer\s+[A-Za-z0-9._~-]+/giu, 'Bearer [redacted]')
  742. .slice(0, 4_000);
  743. }