import { randomUUID } from 'node:crypto'; import { EventEmitter } from 'node:events'; import type { CodexRuntime } from './codexRuntime'; import type { JsonRpcId, JsonRpcServerRequest } from './jsonRpcPeer'; import type { ApprovalAnswers, ApprovalDecision, ApprovalKind, ApprovalRequest } from './types'; /** * 移植自 Noobi.ai src/main/approvalBroker.ts:维度由 projectId 改为 threadId。 * 默认 approvalPolicy 是 'never'(无人值守),本 broker 是为将来切到 on-request 时准备的, * 同时兜住 Codex 主动发来的 currentTime/read 等服务端请求。 */ interface PendingApproval { request: JsonRpcServerRequest; approval: ApprovalRequest; timer: NodeJS.Timeout; } const APPROVAL_TIMEOUT_MS = 2 * 60 * 1_000; type ApprovalRuntime = Pick; export class ApprovalBroker extends EventEmitter { readonly #runtime: ApprovalRuntime; readonly #pending = new Map(); readonly #tokenForRequest = new Map(); constructor(runtime: ApprovalRuntime) { super(); this.#runtime = runtime; } handle(request: JsonRpcServerRequest): void { const kind = approvalKind(request.method); if (!kind) { if (request.method === 'currentTime/read') { this.#runtime.respondToServerRequest(request.id, { currentTimeAt: Math.floor(Date.now() / 1_000), }); return; } this.#runtime.rejectServerRequest(request.id, -32601, `本客户端不支持 ${request.method}`); this.emit('diagnostic', `已拒绝不支持的 Codex 请求:${request.method}`); return; } const params = asRecord(request.params) ?? {}; const token = randomUUID(); const approval: ApprovalRequest = { token, threadId: readString(params.threadId), kind, method: request.method, title: approvalTitle(kind), summary: approvalSummary(kind, params), details: redact(params), createdAt: new Date().toISOString(), }; const timer = setTimeout(() => this.#expire(token), APPROVAL_TIMEOUT_MS); timer.unref(); this.#pending.set(token, { request, approval, timer }); this.#tokenForRequest.set(request.id, token); this.emit('approval', structuredClone(approval)); } resolve(token: string, decision: ApprovalDecision, answers?: ApprovalAnswers): void { const current = this.#pending.get(token); if (!current) throw new Error('该审批已失效'); const response = approvalResponse(current.request, decision, answers); const pending = this.#take(token)!; try { this.#runtime.respondToServerRequest(pending.request.id, response); } finally { this.emit('closed', token); } } /** Codex 自己撤销了请求(serverRequest/resolved)时,本地同步失效 */ resolveFromServer(requestId: JsonRpcId): void { const token = this.#tokenForRequest.get(requestId); if (token && this.#take(token)) this.emit('closed', token); } closeAll(): void { for (const token of [...this.#pending.keys()]) { const pending = this.#take(token); if (!pending) continue; try { this.#runtime.respondToServerRequest( pending.request.id, approvalResponse(pending.request, 'decline'), ); } catch { // 运行时已关闭:待处理审批只在本地失效 } finally { this.emit('closed', token); } } } /** 只本地失效,不往新的或已失败的运行时写响应 */ invalidateAll(): void { for (const token of [...this.#pending.keys()]) { if (this.#take(token)) this.emit('closed', token); } } #expire(token: string): void { const pending = this.#take(token); if (!pending) return; try { this.#runtime.respondToServerRequest( pending.request.id, approvalResponse(pending.request, 'decline'), ); } catch (error) { this.emit('diagnostic', `无法发送过期审批的响应:${asError(error).message}`); } finally { this.emit('expired', pending.approval); this.emit('closed', token); } } #take(token: string): PendingApproval | null { const pending = this.#pending.get(token); if (!pending) return null; clearTimeout(pending.timer); this.#pending.delete(token); if (this.#tokenForRequest.get(pending.request.id) === token) { this.#tokenForRequest.delete(pending.request.id); } return pending; } } function approvalKind(method: string): ApprovalKind | null { if (method === 'item/commandExecution/requestApproval') return 'command'; if (method === 'item/fileChange/requestApproval') return 'file'; if (method === 'item/permissions/requestApproval') return 'permissions'; if (method === 'item/tool/requestUserInput' || method === 'mcpServer/elicitation/request') { return 'input'; } return null; } function approvalResponse( request: JsonRpcServerRequest, decision: ApprovalDecision, answers?: ApprovalAnswers, ): unknown { if ( request.method === 'item/commandExecution/requestApproval' || request.method === 'item/fileChange/requestApproval' ) { return { decision }; } if (request.method === 'item/permissions/requestApproval') { const requested = asRecord(request.params)?.permissions; const accepted = decision === 'accept' || decision === 'acceptForSession'; return { scope: decision === 'acceptForSession' ? 'session' : 'turn', permissions: accepted && requested && typeof requested === 'object' ? requested : {}, }; } if (request.method === 'item/tool/requestUserInput') { return { answers: decision === 'accept' ? validatedAnswers(request.params, answers) : {} }; } if (request.method === 'mcpServer/elicitation/request') { return { action: decision === 'cancel' ? 'cancel' : 'decline', content: null }; } throw new Error(`不支持的审批响应方法:${request.method}`); } function validatedAnswers( params: unknown, answers: ApprovalAnswers | undefined, ): Record { const questions = asRecord(params)?.questions; if (!Array.isArray(questions) || questions.length === 0) return {}; const result: Record = {}; for (const question of questions) { const id = readString(asRecord(question)?.id); if (!id) throw new Error('Codex 的提问缺少 id'); const values = answers?.[id]; if (!Array.isArray(values) || values.length === 0) throw new Error(`请回答 Codex 的提问:${id}`); if (values.length > 20 || values.some((value) => typeof value !== 'string' || value.length > 10_000)) { throw new Error(`提问 ${id} 的回答不合法`); } result[id] = { answers: [...values] }; } return result; } function approvalTitle(kind: ApprovalKind): string { if (kind === 'command') return '允许执行命令?'; if (kind === 'file') return '允许修改文件?'; if (kind === 'permissions') return '允许额外权限?'; return 'Codex 需要你的输入'; } function approvalSummary(kind: ApprovalKind, params: Record): string { if (kind === 'command') { return readString(params.command) ?? readString(params.reason) ?? 'Codex 请求运行一条命令'; } if (kind === 'file') { return readString(params.reason) ?? readString(params.grantRoot) ?? 'Codex 请求写入项目文件'; } if (kind === 'permissions') { return readString(params.reason) ?? 'Codex 请求扩大当前回合的访问范围'; } return readString(params.message) ?? readString(params.reason) ?? '请在 Codex 任务中继续提供信息'; } function redact(value: Record): Record { const clone = structuredClone(value); for (const key of Object.keys(clone)) { if (/token|authorization|api.?key|secret/iu.test(key)) clone[key] = '[redacted]'; } return clone; } function asRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : null; } function readString(value: unknown): string | null { return typeof value === 'string' ? value : null; } function asError(value: unknown): Error { return value instanceof Error ? value : new Error(String(value)); }