feat(agent): implement permission mode handling for Codex threads and approvals

This commit is contained in:
HouYunFei
2026-07-29 16:44:51 +08:00
parent bd8cf318f7
commit b926feeb14
14 changed files with 372 additions and 46 deletions
+49 -10
View File
@@ -7,7 +7,7 @@ import { VERSION } from "../config.js";
import { logger } from "../utils/logger.js";
import { field, type JsonRecord } from "../utils/value.js";
import type { CodexNotificationParams, CodexPlanUpdate, CodexRequestMethod, CodexRequestParams, CodexRequestResult, CodexTurnInput } from "./codex-protocol.js";
import type { AgentEmit } from "./types.js";
import type { AgentEmit, AgentPermissionMode } from "./types.js";
type AgentEvent = JsonRecord & { type: string; usage?: unknown };
type PendingRequest = { resolve: (value: unknown) => void; reject: (error: Error) => void };
@@ -31,6 +31,7 @@ export class CodexAppClient {
private completedTurns = new Map<string, Error | null>();
private pendingDeltas = new Map<string, PendingDelta>();
private plansByTurn = new Map<string, CodexPlanUpdate>();
private approvalRequests = new Map<string, { id: number; method: string; params: JsonRecord }>();
/** 保存 app-server 子进程和事件出口。 */
private constructor(private child: ChildProcess, private emit: AgentEmit) {}
@@ -62,15 +63,15 @@ export class CodexAppClient {
}
/** 创建新的 Codex 线程。 */
async startThread(cwd?: string) {
const { thread } = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}), threadSource: "user" });
async startThread(cwd?: string, permissionMode: AgentPermissionMode = "request") {
const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
return thread;
}
/** 恢复已有 Codex 线程。 */
async resumeThread(threadId: string, cwd?: string) {
const { thread } = await this.request("thread/resume", { threadId, approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}) });
async resumeThread(threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
if (!thread.id) throw new Error("Codex app-server 没有返回 thread id");
return thread;
}
@@ -103,9 +104,9 @@ export class CodexAppClient {
}
/** 启动一个 Codex turn 并等待完成通知。 */
async startTurn(threadId: string, prompt: string, images: string[], onTurn?: (turnId: string) => void) {
async startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void) {
this.currentThreadId = threadId;
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) });
const turnId = turn.id;
if (!turnId) throw new Error("Codex app-server 没有返回 turn id");
this.currentTurnId = turnId;
@@ -136,6 +137,19 @@ export class CodexAppClient {
}
}
/** 回复网页端已经确认的 Codex 权限请求。 */
resolveApproval(requestId: string, decision: string) {
const request = this.approvalRequests.get(requestId);
if (!request) return false;
this.approvalRequests.delete(requestId);
const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
const result = request.method === "item/permissions/requestApproval"
? { permissions: decision === "decline" ? {} : permissions || {}, scope: decision === "acceptForSession" ? "session" : "turn" }
: { decision };
this.write({ id: request.id, result });
return true;
}
/** 发送 JSON-RPC 请求并保存待处理 Promise。 */
private request<Method extends CodexRequestMethod>(method: Method, params: CodexRequestParams<Method>) {
const id = this.nextId++;
@@ -187,6 +201,12 @@ export class CodexAppClient {
/** 转换并广播 app-server 通知。 */
private handleNotification(method: string, params: JsonRecord) {
if (method === "serverRequest/resolved") {
const requestId = String(field(params, "requestId") || "");
if (requestId) this.approvalRequests.delete(requestId);
this.emit("codex_approval_resolved", { requestId, ...params });
return;
}
if (!field(params, "threadId") && this.currentThreadId && (method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated")) params = { ...params, threadId: this.currentThreadId };
if (method === "item/agentMessage/delta") {
const value = params as unknown as CodexNotificationParams<"item/agentMessage/delta">;
@@ -274,9 +294,16 @@ export class CodexAppClient {
/** 自动回复 app-server 发起的授权或交互请求。 */
private answerServerRequest(message: JsonRecord) {
const method = String(message.method);
const params = (field(message, "params") as JsonRecord) || {};
if (["item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval"].includes(method)) {
const requestId = String(message.id);
this.approvalRequests.set(requestId, { id: Number(message.id), method, params });
this.emit("codex_approval", { requestId, method, ...params });
return;
}
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
this.write({ id: message.id, result });
this.emit("agent_event", { agent: "codex", type: "server.request", method, params: message.params, result });
this.emit("agent_event", { agent: "codex", type: "server.request", method, params, result });
}
/** 完成指定 JSON-RPC 请求。 */
@@ -299,6 +326,7 @@ export class CodexAppClient {
this.activeTurns.clear();
this.pendingDeltas.clear();
this.textByItem.clear();
this.approvalRequests.clear();
this.currentThreadId = "";
this.currentTurnId = "";
}
@@ -313,8 +341,19 @@ function canvasAgentMcpCommand() {
}
/** 生成 Codex app-server 使用的 MCP 配置。 */
function codexConfig() {
return { model_reasoning_summary: "auto", mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
function codexConfig(permissionMode: AgentPermissionMode) {
return { model_reasoning_summary: "auto", ...(permissionMode === "automatic" ? { approvals_reviewer: "auto_review" } : {}), mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
}
function threadSettings(permissionMode: AgentPermissionMode) {
return { approvalPolicy: permissionMode === "full" ? "never" as const : "onRequest" as const, sandbox: permissionMode === "full" ? "dangerFullAccess" as const : "workspaceWrite" as const, config: codexConfig(permissionMode) };
}
function turnSettings(permissionMode: AgentPermissionMode) {
return {
approvalPolicy: permissionMode === "full" ? "never" as const : "onRequest" as const,
sandboxPolicy: permissionMode === "full" ? { type: "dangerFullAccess" as const } : { type: "workspaceWrite" as const, networkAccess: false },
};
}
/** 将文本和本地图片转换为 Codex turn 输入。 */
+3 -3
View File
@@ -12,8 +12,8 @@ export type CodexTurnInput =
| { type: "localImage"; path: string };
type ThreadOptions = {
approvalPolicy: "never";
sandbox: "workspace-write";
approvalPolicy: "never" | "onRequest";
sandbox: "workspaceWrite" | "dangerFullAccess";
config: JsonRecord;
cwd?: string;
};
@@ -54,7 +54,7 @@ type CodexRequestSpec = {
result: Record<string, never>;
};
"turn/start": {
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" };
params: { threadId: string; input: CodexTurnInput[]; approvalPolicy: "never" | "onRequest"; sandboxPolicy: { type: "workspaceWrite"; networkAccess: boolean } | { type: "dangerFullAccess" } };
result: { turn: CodexTurn };
};
"turn/interrupt": {
+15 -10
View File
@@ -6,9 +6,9 @@ import { logger } from "../utils/logger.js";
import { errorMessage, field } from "../utils/value.js";
import { CodexAppClient } from "./codex-client.js";
import { summarizeCodexThread, threadMessages } from "./codex-history.js";
import type { AgentAttachment, AgentEmit } from "./types.js";
import type { AgentAttachment, AgentEmit, AgentPermissionMode } from "./types.js";
type CodexRunOptions = { threadId?: string; cwd?: string; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
type CodexRunOptions = { threadId?: string; cwd?: string; permissionMode?: AgentPermissionMode; appEmit?: AgentEmit; onStart?: () => void; onThread?: (threadId: string) => void; onTurn?: (turnId: string) => void; onFinish?: () => void };
let codexQueue: Promise<unknown> = Promise.resolve();
let codexApp: CodexAppClient | null = null;
@@ -31,20 +31,25 @@ export async function interruptCodexTurn(threadId?: string) {
return await codexApp.interruptCurrentTurn();
}
/** 回复当前 app-server 的待处理权限请求。 */
export async function resolveCodexApproval(requestId: string, decision: string) {
return Boolean(codexApp?.resolveApproval(requestId, decision));
}
/** 创建新的 Codex 线程并记录当前线程 ID。 */
export async function startCodexThread(emit: AgentEmit, cwd?: string) {
export async function startCodexThread(emit: AgentEmit, cwd?: string, permissionMode: AgentPermissionMode = "request") {
const app = await getCodexApp(emit);
const thread = await app.startThread(cwd);
const thread = await app.startThread(cwd, permissionMode);
codexThreadId = String(field(thread, "id") || "");
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
return thread;
}
/** 恢复指定 Codex 线程并返回聊天历史。 */
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string) {
export async function resumeCodexThread(emit: AgentEmit, threadId: string, cwd?: string, permissionMode: AgentPermissionMode = "request") {
const app = await getCodexApp(emit);
await loadCodexThread(emit, threadId, cwd, false);
const thread = await app.resumeThread(threadId, cwd);
const thread = await app.resumeThread(threadId, cwd, permissionMode);
assertThreadWorkspace(thread, cwd);
codexThreadId = String(field(thread, "id") || threadId);
const historyThread = await loadCodexThread(emit, codexThreadId, cwd, true);
@@ -110,7 +115,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
options.onThread?.(threadId);
unmaterializedThreadIds.delete(threadId);
try {
await app.startTurn(threadId, prompt, files, options.onTurn);
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
} catch (error) {
if (!isRecoverableThreadError(error)) throw error;
emit("agent_log", { text: `Codex thread unavailable, starting a new thread: ${errorMessage(error)}` });
@@ -118,7 +123,7 @@ async function runCodexTurnNow(prompt: string, emit: AgentEmit, attachments: Age
threadId = await ensureCodexThread(app, { cwd: options.cwd }, emit);
options.onThread?.(threadId);
unmaterializedThreadIds.delete(threadId);
await app.startTurn(threadId, prompt, files, options.onTurn);
await app.startTurn(threadId, prompt, files, options.permissionMode || "request", options.onTurn);
}
} catch (error) {
logger.error("Codex turn failed", error);
@@ -136,7 +141,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions,
try {
const result = await app.readThread(options.threadId, false);
assertThreadWorkspace(field(result, "thread") || {}, options.cwd);
const thread = await app.resumeThread(options.threadId, options.cwd);
const thread = await app.resumeThread(options.threadId, options.cwd, options.permissionMode || "request");
assertThreadWorkspace(thread, options.cwd);
codexThreadId = String(field(thread, "id") || options.threadId);
return codexThreadId;
@@ -146,7 +151,7 @@ async function ensureCodexThread(app: CodexAppClient, options: CodexRunOptions,
}
}
if (!codexThreadId) {
const thread = await app.startThread(options.cwd);
const thread = await app.startThread(options.cwd, options.permissionMode || "request");
codexThreadId = String(field(thread, "id") || "");
if (codexThreadId) unmaterializedThreadIds.add(codexThreadId);
}
+3
View File
@@ -3,3 +3,6 @@ export type AgentEmit = (type: string, payload: unknown) => void;
/** 用户随当前 Agent 消息上传的附件。 */
export type AgentAttachment = { id?: string; name?: string; type?: string; size?: number; width?: number; height?: number; dataUrl?: string };
/** Codex 文件、命令和网络权限模式。 */
export type AgentPermissionMode = "request" | "automatic" | "full";
+17 -6
View File
@@ -1,8 +1,8 @@
import express, { type NextFunction, type Request, type Response } from "express";
import { runClaudeTurn } from "../agent/claude.js";
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
import type { AgentAttachment } from "../agent/types.js";
import { archiveCodexThread, interruptCodexTurn, isRecoverableThreadError, listCodexThreads, readCodexThread, resolveCodexApproval, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread, verifyCodexThreadWorkspace } from "../agent/codex.js";
import type { AgentAttachment, AgentPermissionMode } from "../agent/types.js";
import { CanvasSession } from "../canvas/session.js";
import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace, type CanvasAgentConfig } from "../config.js";
import { logger } from "../utils/logger.js";
@@ -82,10 +82,10 @@ export function startHttpServer() {
const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
res.json({ ok: true, workspace, ...result });
}));
app.post("/agent/codex/threads/new", route(async (_req, res) => {
app.post("/agent/codex/threads/new", route(async (req, res) => {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const workspace = ensureSiteWorkspace(config);
const thread = await startCodexThread(emit, workspace.workspacePath);
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
const activeThreadId = String((thread as Record<string, unknown>).id || "");
const nextWorkspace = setActiveThread(activeThreadId, { emptyThread: true });
res.json({ ok: true, workspace: nextWorkspace, thread: summarizeCodexThread(thread), messages: [] });
@@ -104,7 +104,7 @@ export function startHttpServer() {
if (session.codexBusy) return res.status(409).json({ ok: false, error: "Codex 正在运行,请等待当前任务完成" });
const workspace = ensureSiteWorkspace(config);
const threadId = routeParam(req.params.threadId);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath);
const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permissionMode(req.body?.permissionMode));
const nextWorkspace = setActiveThread(threadId);
res.json({ ok: true, workspace: nextWorkspace, ...result });
}));
@@ -129,7 +129,7 @@ export function startHttpServer() {
let threadId = String(req.body?.threadId || workspace.activeThreadId || "");
let turnId = "";
if (!threadId) {
const thread = await startCodexThread(emit, workspace.workspacePath);
const thread = await startCodexThread(emit, workspace.workspacePath, permissionMode(req.body?.permissionMode));
threadId = String((thread as Record<string, unknown>).id || "");
setActiveThread(threadId, { emptyThread: true });
} else if (threadId !== workspace.activeThreadId) {
@@ -150,6 +150,7 @@ export function startHttpServer() {
void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), turnEmit, attachments, {
threadId,
cwd: workspace.workspacePath,
permissionMode: permissionMode(req.body?.permissionMode),
appEmit: emit,
onStart: clientId ? () => session.bindClient(clientId) : undefined,
onThread: (actualThreadId) => {
@@ -181,6 +182,12 @@ export function startHttpServer() {
throw error;
}
}));
app.post("/agent/codex/approval", route(async (req, res) => {
const decision = String(req.body?.decision || "");
if (!["accept", "acceptForSession", "decline", "cancel"].includes(decision)) return res.status(400).json({ ok: false, error: "无效的审批决定" });
const ok = await resolveCodexApproval(String(req.body?.requestId || ""), decision);
res.status(ok ? 200 : 409).json({ ok, ...(ok ? {} : { error: "审批请求已失效" }) });
}));
app.post("/agent/codex/interrupt", route(async (req, res) => res.json({ ok: await interruptCodexTurn(String(req.body?.threadId || "")) })));
app.post("/agent/claude/turn", (req, res) => {
runClaudeTurn(String(req.body?.prompt || ""), emit);
@@ -214,6 +221,10 @@ function routeParam(value: string | string[]) {
return Array.isArray(value) ? value[0] || "" : value;
}
function permissionMode(value: unknown): AgentPermissionMode {
return value === "automatic" || value === "full" ? value : "request";
}
/** 结合服务配置解析当前请求 URL。 */
function requestUrl(req: Request, config: CanvasAgentConfig) {
return new URL(req.originalUrl || req.url || "/", config.url);