approvalBroker.ts 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. import { randomUUID } from 'node:crypto';
  2. import { EventEmitter } from 'node:events';
  3. import type { CodexRuntime } from './codexRuntime';
  4. import type { JsonRpcId, JsonRpcServerRequest } from './jsonRpcPeer';
  5. import type { ApprovalAnswers, ApprovalDecision, ApprovalKind, ApprovalRequest } from './types';
  6. /**
  7. * 移植自 Noobi.ai src/main/approvalBroker.ts:维度由 projectId 改为 threadId。
  8. * 默认 approvalPolicy 是 'never'(无人值守),本 broker 是为将来切到 on-request 时准备的,
  9. * 同时兜住 Codex 主动发来的 currentTime/read 等服务端请求。
  10. */
  11. interface PendingApproval {
  12. request: JsonRpcServerRequest;
  13. approval: ApprovalRequest;
  14. timer: NodeJS.Timeout;
  15. }
  16. const APPROVAL_TIMEOUT_MS = 2 * 60 * 1_000;
  17. type ApprovalRuntime = Pick<CodexRuntime, 'respondToServerRequest' | 'rejectServerRequest'>;
  18. export class ApprovalBroker extends EventEmitter {
  19. readonly #runtime: ApprovalRuntime;
  20. readonly #pending = new Map<string, PendingApproval>();
  21. readonly #tokenForRequest = new Map<JsonRpcId, string>();
  22. constructor(runtime: ApprovalRuntime) {
  23. super();
  24. this.#runtime = runtime;
  25. }
  26. handle(request: JsonRpcServerRequest): void {
  27. const kind = approvalKind(request.method);
  28. if (!kind) {
  29. if (request.method === 'currentTime/read') {
  30. this.#runtime.respondToServerRequest(request.id, {
  31. currentTimeAt: Math.floor(Date.now() / 1_000),
  32. });
  33. return;
  34. }
  35. this.#runtime.rejectServerRequest(request.id, -32601, `本客户端不支持 ${request.method}`);
  36. this.emit('diagnostic', `已拒绝不支持的 Codex 请求:${request.method}`);
  37. return;
  38. }
  39. const params = asRecord(request.params) ?? {};
  40. const token = randomUUID();
  41. const approval: ApprovalRequest = {
  42. token,
  43. threadId: readString(params.threadId),
  44. kind,
  45. method: request.method,
  46. title: approvalTitle(kind),
  47. summary: approvalSummary(kind, params),
  48. details: redact(params),
  49. createdAt: new Date().toISOString(),
  50. };
  51. const timer = setTimeout(() => this.#expire(token), APPROVAL_TIMEOUT_MS);
  52. timer.unref();
  53. this.#pending.set(token, { request, approval, timer });
  54. this.#tokenForRequest.set(request.id, token);
  55. this.emit('approval', structuredClone(approval));
  56. }
  57. resolve(token: string, decision: ApprovalDecision, answers?: ApprovalAnswers): void {
  58. const current = this.#pending.get(token);
  59. if (!current) throw new Error('该审批已失效');
  60. const response = approvalResponse(current.request, decision, answers);
  61. const pending = this.#take(token)!;
  62. try {
  63. this.#runtime.respondToServerRequest(pending.request.id, response);
  64. } finally {
  65. this.emit('closed', token);
  66. }
  67. }
  68. /** Codex 自己撤销了请求(serverRequest/resolved)时,本地同步失效 */
  69. resolveFromServer(requestId: JsonRpcId): void {
  70. const token = this.#tokenForRequest.get(requestId);
  71. if (token && this.#take(token)) this.emit('closed', token);
  72. }
  73. closeAll(): void {
  74. for (const token of [...this.#pending.keys()]) {
  75. const pending = this.#take(token);
  76. if (!pending) continue;
  77. try {
  78. this.#runtime.respondToServerRequest(
  79. pending.request.id,
  80. approvalResponse(pending.request, 'decline'),
  81. );
  82. } catch {
  83. // 运行时已关闭:待处理审批只在本地失效
  84. } finally {
  85. this.emit('closed', token);
  86. }
  87. }
  88. }
  89. /** 只本地失效,不往新的或已失败的运行时写响应 */
  90. invalidateAll(): void {
  91. for (const token of [...this.#pending.keys()]) {
  92. if (this.#take(token)) this.emit('closed', token);
  93. }
  94. }
  95. #expire(token: string): void {
  96. const pending = this.#take(token);
  97. if (!pending) return;
  98. try {
  99. this.#runtime.respondToServerRequest(
  100. pending.request.id,
  101. approvalResponse(pending.request, 'decline'),
  102. );
  103. } catch (error) {
  104. this.emit('diagnostic', `无法发送过期审批的响应:${asError(error).message}`);
  105. } finally {
  106. this.emit('expired', pending.approval);
  107. this.emit('closed', token);
  108. }
  109. }
  110. #take(token: string): PendingApproval | null {
  111. const pending = this.#pending.get(token);
  112. if (!pending) return null;
  113. clearTimeout(pending.timer);
  114. this.#pending.delete(token);
  115. if (this.#tokenForRequest.get(pending.request.id) === token) {
  116. this.#tokenForRequest.delete(pending.request.id);
  117. }
  118. return pending;
  119. }
  120. }
  121. function approvalKind(method: string): ApprovalKind | null {
  122. if (method === 'item/commandExecution/requestApproval') return 'command';
  123. if (method === 'item/fileChange/requestApproval') return 'file';
  124. if (method === 'item/permissions/requestApproval') return 'permissions';
  125. if (method === 'item/tool/requestUserInput' || method === 'mcpServer/elicitation/request') {
  126. return 'input';
  127. }
  128. return null;
  129. }
  130. function approvalResponse(
  131. request: JsonRpcServerRequest,
  132. decision: ApprovalDecision,
  133. answers?: ApprovalAnswers,
  134. ): unknown {
  135. if (
  136. request.method === 'item/commandExecution/requestApproval' ||
  137. request.method === 'item/fileChange/requestApproval'
  138. ) {
  139. return { decision };
  140. }
  141. if (request.method === 'item/permissions/requestApproval') {
  142. const requested = asRecord(request.params)?.permissions;
  143. const accepted = decision === 'accept' || decision === 'acceptForSession';
  144. return {
  145. scope: decision === 'acceptForSession' ? 'session' : 'turn',
  146. permissions: accepted && requested && typeof requested === 'object' ? requested : {},
  147. };
  148. }
  149. if (request.method === 'item/tool/requestUserInput') {
  150. return { answers: decision === 'accept' ? validatedAnswers(request.params, answers) : {} };
  151. }
  152. if (request.method === 'mcpServer/elicitation/request') {
  153. return { action: decision === 'cancel' ? 'cancel' : 'decline', content: null };
  154. }
  155. throw new Error(`不支持的审批响应方法:${request.method}`);
  156. }
  157. function validatedAnswers(
  158. params: unknown,
  159. answers: ApprovalAnswers | undefined,
  160. ): Record<string, { answers: string[] }> {
  161. const questions = asRecord(params)?.questions;
  162. if (!Array.isArray(questions) || questions.length === 0) return {};
  163. const result: Record<string, { answers: string[] }> = {};
  164. for (const question of questions) {
  165. const id = readString(asRecord(question)?.id);
  166. if (!id) throw new Error('Codex 的提问缺少 id');
  167. const values = answers?.[id];
  168. if (!Array.isArray(values) || values.length === 0) throw new Error(`请回答 Codex 的提问:${id}`);
  169. if (values.length > 20 || values.some((value) => typeof value !== 'string' || value.length > 10_000)) {
  170. throw new Error(`提问 ${id} 的回答不合法`);
  171. }
  172. result[id] = { answers: [...values] };
  173. }
  174. return result;
  175. }
  176. function approvalTitle(kind: ApprovalKind): string {
  177. if (kind === 'command') return '允许执行命令?';
  178. if (kind === 'file') return '允许修改文件?';
  179. if (kind === 'permissions') return '允许额外权限?';
  180. return 'Codex 需要你的输入';
  181. }
  182. function approvalSummary(kind: ApprovalKind, params: Record<string, unknown>): string {
  183. if (kind === 'command') {
  184. return readString(params.command) ?? readString(params.reason) ?? 'Codex 请求运行一条命令';
  185. }
  186. if (kind === 'file') {
  187. return readString(params.reason) ?? readString(params.grantRoot) ?? 'Codex 请求写入项目文件';
  188. }
  189. if (kind === 'permissions') {
  190. return readString(params.reason) ?? 'Codex 请求扩大当前回合的访问范围';
  191. }
  192. return readString(params.message) ?? readString(params.reason) ?? '请在 Codex 任务中继续提供信息';
  193. }
  194. function redact(value: Record<string, unknown>): Record<string, unknown> {
  195. const clone = structuredClone(value);
  196. for (const key of Object.keys(clone)) {
  197. if (/token|authorization|api.?key|secret/iu.test(key)) clone[key] = '[redacted]';
  198. }
  199. return clone;
  200. }
  201. function asRecord(value: unknown): Record<string, unknown> | null {
  202. return value && typeof value === 'object' && !Array.isArray(value)
  203. ? (value as Record<string, unknown>)
  204. : null;
  205. }
  206. function readString(value: unknown): string | null {
  207. return typeof value === 'string' ? value : null;
  208. }
  209. function asError(value: unknown): Error {
  210. return value instanceof Error ? value : new Error(String(value));
  211. }