mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 08:54:23 +08:00
feat(agent): implement permission mode handling for Codex threads and approvals
This commit is contained in:
@@ -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 输入。 */
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user