| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- 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<CodexRuntime, 'respondToServerRequest' | 'rejectServerRequest'>;
- export class ApprovalBroker extends EventEmitter {
- readonly #runtime: ApprovalRuntime;
- readonly #pending = new Map<string, PendingApproval>();
- readonly #tokenForRequest = new Map<JsonRpcId, string>();
- 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<string, { answers: string[] }> {
- const questions = asRecord(params)?.questions;
- if (!Array.isArray(questions) || questions.length === 0) return {};
- const result: Record<string, { answers: string[] }> = {};
- 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, unknown>): 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<string, unknown>): Record<string, unknown> {
- 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<string, unknown> | null {
- return value && typeof value === 'object' && !Array.isArray(value)
- ? (value as Record<string, unknown>)
- : 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));
- }
|