feat: support codex app goal command

This commit is contained in:
shiyue
2026-06-17 14:08:32 +08:00
parent 7e01f24e61
commit b4bcd170d2
8 changed files with 3129 additions and 23 deletions

587
server.js
View File

@@ -68,6 +68,7 @@ const RUN_OUTPUT_RECOVERY_MAX_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_RECO
const RUN_OUTPUT_TAILER_MAX_READ_BYTES = readPositiveIntEnv('CC_WEB_RUN_OUTPUT_TAILER_MAX_READ_BYTES', 2 * 1024 * 1024, { min: 64 * 1024 });
const CROSS_CONVERSATION_MAX_CONTENT_CHARS = readPositiveIntEnv('CC_WEB_CROSS_CONVERSATION_MAX_CONTENT_CHARS', 64 * 1024, { min: 1024 });
const MCP_CREATE_CONVERSATION_MAX_HOP_COUNT = readPositiveIntEnv('CC_WEB_MCP_CREATE_CONVERSATION_MAX_HOP_COUNT', 3, { min: 1, max: 20 });
const MAX_CODEX_GOAL_OBJECTIVE_CHARS = 4000;
const CODEX_APP_WORKER_DISABLED = /^(0|false|no|off)$/i.test(String(process.env.CC_WEB_CODEX_APP_WORKER || ''));
const CODEX_APP_WORKER_ENABLED = !CODEX_APP_WORKER_DISABLED;
const CODEX_APP_PROCESS_ENV_STRIP_KEYS = [
@@ -611,6 +612,8 @@ const ccwebMcpChildThreads = new Map();
const pendingCrossConversationReplies = new Map();
// Pending Codex app-server guided input requests: requestId -> { sessionId, resolve, timer }
const pendingCodexAppUserInputs = new Map();
// Pending Codex app-server approval requests: requestId -> { sessionId, method, params, resolve, timer }
const pendingCodexAppApprovals = new Map();
let codexAppClient = null;
let codexAppClientSignature = '';
const CODEX_APP_STATE_FILE = 'codexapp-state.json';
@@ -714,6 +717,69 @@ function parseTomlStringValue(value) {
return raw;
}
function parseTomlBareKeyPath(pathText) {
const parts = [];
let current = '';
let quote = '';
let escaped = false;
for (const ch of String(pathText || '').trim()) {
if (quote) {
if (escaped) {
current += ch;
escaped = false;
continue;
}
if (ch === '\\' && quote === '"') {
escaped = true;
continue;
}
if (ch === quote) {
quote = '';
continue;
}
current += ch;
continue;
}
if (ch === '"' || ch === '\'') {
quote = ch;
continue;
}
if (ch === '.') {
if (current.trim()) parts.push(current.trim());
current = '';
continue;
}
current += ch;
}
if (current.trim()) parts.push(current.trim());
return parts.filter(Boolean);
}
function loadCodexMcpServerNamesFromToml() {
try {
const configPath = getLocalCodexConfigTomlPath();
if (!configPath || !fs.existsSync(configPath)) return [];
const names = [];
const seen = new Set();
const text = fs.readFileSync(configPath, 'utf8');
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const match = trimmed.match(/^\[([^\]]+)\]$/);
if (!match) continue;
const parts = parseTomlBareKeyPath(match[1]);
if (parts[0] !== 'mcp_servers' || !parts[1]) continue;
const name = String(parts[1]).trim();
if (!name || seen.has(name)) continue;
seen.add(name);
names.push(name);
}
return names;
} catch {
return [];
}
}
function loadLocalCodexTomlConfig() {
try {
const configPath = getLocalCodexConfigTomlPath();
@@ -1390,6 +1456,7 @@ const COMPOSER_COMMANDS = [
{ name: '/mode', description: '查看/切换权限模式', insertion: '/mode ' },
{ name: '/cost', description: '查看会话费用或 Token', insertion: '/cost ' },
{ name: '/compact', description: '压缩上下文', insertion: '/compact ' },
{ name: '/goal', description: '设置/查看/暂停/恢复/清除 Codex App 持久目标', insertion: '/goal ' },
{ name: '/init', description: '生成/更新 Agent 指南文件', insertion: '/init ' },
{ name: '/help', description: '显示帮助', insertion: '/help ' },
];
@@ -1619,11 +1686,100 @@ function filterComposerItems(items, query) {
return filtered.slice(0, COMPOSER_SUGGESTION_LIMIT);
}
function listComposerMcpTools() {
return CCWEB_MCP_TOOLS.map((tool) => {
function normalizeMcpServerName(value) {
return String(value || '').trim().replace(/^mcp:/, '').replace(/^mcp__/, '').replace(/__.*$/, '');
}
function isLikelyMcpServerName(value) {
const name = normalizeMcpServerName(value);
if (!name || name.length > 80) return false;
if (!/^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name)) return false;
return !new Set([
'content', 'text', 'input', 'result', 'status', 'server', 'tool', 'mcp',
'true', 'false', 'null', 'undefined', 'currentConversationId',
]).has(name);
}
function collectComposerMcpNamesFromText(text, names) {
const value = String(text || '');
for (const match of value.matchAll(/mcp__([A-Za-z0-9_.-]+)__[A-Za-z0-9_.-]+/g)) {
const name = normalizeMcpServerName(match[1]);
if (isLikelyMcpServerName(name)) names.add(name);
}
for (const match of value.matchAll(/\bmcp:([A-Za-z0-9_.-]+)(?:\/[A-Za-z0-9_.-]+)?/g)) {
const name = normalizeMcpServerName(match[1]);
if (isLikelyMcpServerName(name)) names.add(name);
}
}
function collectComposerMcpNamesFromValue(value, names, depth = 0) {
if (depth > 5 || !value) return;
if (typeof value === 'string') {
collectComposerMcpNamesFromText(value, names);
return;
}
if (Array.isArray(value)) {
for (const item of value.slice(0, 80)) collectComposerMcpNamesFromValue(item, names, depth + 1);
return;
}
if (typeof value !== 'object') return;
for (const [key, item] of Object.entries(value).slice(0, 120)) {
collectComposerMcpNamesFromText(key, names);
collectComposerMcpNamesFromValue(item, names, depth + 1);
}
}
function loadComposerMcpServerNamesFromSession(sessionId) {
const names = new Set();
const session = sessionId ? loadSession(sessionId) : null;
collectComposerMcpNamesFromValue(session?.messages || [], names);
const state = sessionId ? loadCodexAppTurnState(sessionId) : null;
if (state && !state.__invalid) collectComposerMcpNamesFromValue(state, names);
return [...names].filter((name) => isLikelyMcpServerName(name));
}
function mcpServerSuggestion(name, options = {}) {
const server = normalizeMcpServerName(name);
if (!isLikelyMcpServerName(server)) return null;
return {
kind: 'mcp',
name: server,
label: `mcp:${server}`,
title: `${server} MCP`,
description: options.description || `MCP server: ${server}`,
insertion: `mcp:${server}`,
appendSpace: true,
server,
source: options.source || 'mcp',
itemType: 'server',
};
}
function listComposerMcpItems(sessionId) {
const items = [];
const seen = new Set();
const push = (item) => {
if (!item?.server && !item?.name) return;
const key = `${item.kind || ''}:${item.server || ''}:${item.name || item.label || item.insertion || ''}`;
if (seen.has(key)) return;
seen.add(key);
items.push(item);
};
for (const name of loadComposerMcpServerNamesFromSession(sessionId)) {
push(mcpServerSuggestion(name, { source: 'session' }));
}
for (const name of loadCodexMcpServerNamesFromToml()) {
push(mcpServerSuggestion(name, { source: 'codex-config' }));
}
push(mcpServerSuggestion('ccweb', {
source: 'builtin',
description: 'ccweb 内置 MCP server可用于跨会话协作。',
}));
for (const tool of CCWEB_MCP_TOOLS) {
const name = String(tool?.name || '').trim();
const label = `mcp:ccweb/${name}`;
return {
push({
kind: 'mcp',
name,
label,
@@ -1633,8 +1789,10 @@ function listComposerMcpTools() {
appendSpace: true,
server: 'ccweb',
source: 'mcp:ccweb',
};
}).filter((item) => item.name);
itemType: 'tool',
});
}
return items;
}
function mergeComposerSuggestionGroups(...groups) {
@@ -1705,7 +1863,7 @@ function listComposerFileSuggestions(sessionId, query) {
}
function listComposerSuggestions(trigger, query, sessionId, agent) {
const mcpItems = filterComposerItems(listComposerMcpTools(), query);
const mcpItems = filterComposerItems(listComposerMcpItems(sessionId), query);
if (trigger === '/') {
const commands = filterComposerItems(COMPOSER_COMMANDS.map((cmd) => ({
kind: 'command',
@@ -3612,6 +3770,10 @@ function isContextLimitError(agent, raw) {
function handleProcessComplete(sessionId, exitCode, signal) {
const entry = activeProcesses.get(sessionId);
if (!entry) return;
if (entry.pidMonitorCompleteTimer) {
clearTimeout(entry.pidMonitorCompleteTimer);
entry.pidMonitorCompleteTimer = null;
}
const completeTime = new Date().toISOString();
const wsConnected = !!entry.ws;
@@ -3622,6 +3784,12 @@ function handleProcessComplete(sessionId, exitCode, signal) {
const pendingRetry = pendingCompactRetries.get(sessionId) || null;
let contextLimitExceeded = false;
// 先读完剩余 JSONL再判定错误类型避免退出监控早于 tailer 造成漏判。
if (entry.tailer) {
entry.tailer.readNew();
entry.tailer.stop();
}
// Read stderr for error clues
let stderrSnippet = '';
try {
@@ -3659,12 +3827,6 @@ function handleProcessComplete(sessionId, exitCode, signal) {
requestTooLarge: contextLimitExceeded,
});
// Final read
if (entry.tailer) {
entry.tailer.readNew();
entry.tailer.stop();
}
const pendingSlash = pendingSlashCommands.get(sessionId) || null;
if (pendingSlash) pendingSlashCommands.delete(sessionId);
@@ -3790,12 +3952,18 @@ function handleProcessComplete(sessionId, exitCode, signal) {
setInterval(() => {
for (const [sessionId, entry] of activeProcesses) {
if (entry.pid && !isProcessRunning(entry.pid)) {
if (entry.pidMonitorCompleteTimer) continue;
plog('INFO', 'pid_monitor_detected_exit', {
sessionId: sessionId.slice(0, 8),
pid: entry.pid,
wsConnected: !!entry.ws,
});
handleProcessComplete(sessionId, null, 'unknown (detected by monitor)');
entry.pidMonitorCompleteTimer = setTimeout(() => {
const latest = activeProcesses.get(sessionId);
if (!latest || latest.pid !== entry.pid) return;
latest.pidMonitorCompleteTimer = null;
handleProcessComplete(sessionId, null, 'unknown (detected by monitor)');
}, 1000);
}
}
}, 2000);
@@ -4165,6 +4333,9 @@ wss.on('connection', (ws, req) => {
case 'codex_app_user_input_response':
handleCodexAppUserInputResponse(ws, msg);
break;
case 'codex_app_approval_response':
handleCodexAppApprovalResponse(ws, msg);
break;
case 'ccweb_mcp_child_agent_close':
handleCcwebMcpChildAgentClose(ws, msg);
break;
@@ -4494,6 +4665,171 @@ function handleFetchModels(ws, msg) {
req.end();
}
function parseCodexGoalCommand(text) {
const match = /^\s*\/goal(?:\s+([\s\S]*))?$/i.exec(String(text || ''));
if (!match) return null;
const rest = String(match[1] || '').trim();
if (!rest) return { action: 'show' };
const lower = rest.toLowerCase();
if (lower === 'clear') return { action: 'clear' };
if (lower === 'pause') return { action: 'pause' };
if (lower === 'resume') return { action: 'resume' };
if ([...rest].length > MAX_CODEX_GOAL_OBJECTIVE_CHARS) {
return {
action: 'set',
error: `Goal 目标最多 ${MAX_CODEX_GOAL_OBJECTIVE_CHARS} 个字符。`,
};
}
return { action: 'set', objective: rest };
}
function goalNumber(value) {
if (typeof value === 'number' && Number.isFinite(value)) return value;
if (typeof value === 'string' && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return null;
}
function goalString(value) {
if (value === null || value === undefined) return '';
return String(value).trim();
}
function normalizeCodexThreadGoal(goal, fallbackThreadId = '') {
if (!goal || typeof goal !== 'object') return null;
const threadId = goalString(goal.threadId || goal.thread_id || fallbackThreadId);
const objective = goalString(goal.objective);
const rawStatus = goalString(goal.status) || 'active';
return {
...goal,
threadId,
objective,
status: rawStatus,
tokenBudget: goalNumber(goal.tokenBudget ?? goal.token_budget),
tokensUsed: goalNumber(goal.tokensUsed ?? goal.tokens_used) || 0,
timeUsedSeconds: goalNumber(goal.timeUsedSeconds ?? goal.time_used_seconds) || 0,
createdAt: goalNumber(goal.createdAt ?? goal.created_at) || 0,
updatedAt: goalNumber(goal.updatedAt ?? goal.updated_at) || 0,
};
}
function formatCodexGoalStatus(status) {
const normalized = String(status || 'active').trim();
const compact = normalized.toLowerCase().replace(/[\s_-]/g, '');
if (compact === 'budgetlimited') return 'budget limited';
if (compact === 'complete' || compact === 'completed') return 'complete';
if (compact === 'paused') return 'paused';
if (compact === 'active') return 'active';
if (compact === 'blocked') return 'blocked';
return normalized || 'updated';
}
function formatCodexGoalUsage(goal) {
const normalized = normalizeCodexThreadGoal(goal);
if (!normalized) return 'Goal updated';
const parts = [`Goal ${formatCodexGoalStatus(normalized.status)}`];
if (normalized.tokenBudget !== null) {
parts.push(`${normalized.tokensUsed}/${normalized.tokenBudget} tokens`);
} else if (normalized.tokensUsed > 0) {
parts.push(`${normalized.tokensUsed} tokens`);
}
const objective = normalized.objective ? `\n目标: ${normalized.objective}` : '';
return `${parts.join(' · ')}${objective}`;
}
async function ensureCodexAppGoalThread(session) {
const clientResult = getCodexAppClient();
if (clientResult.error) throw new Error(clientResult.error);
const client = clientResult.client;
await client.start();
let threadId = getRuntimeSessionId(session);
const threadParams = codexAppThreadParams(session);
if (threadId) {
const resumed = await client.request('thread/resume', { ...threadParams, threadId }, 60000);
threadId = resumed?.thread?.id || threadId;
} else {
const started = await client.request('thread/start', { ...threadParams, sessionStartSource: 'startup' }, 60000);
threadId = started?.thread?.id || null;
}
if (!threadId) throw new Error('Codex app-server 未返回 threadId。');
setRuntimeSessionId(session, threadId);
session.updated = new Date().toISOString();
saveSession(session);
return { client, threadId };
}
function isCodexGoalUnsupportedError(err) {
const detail = `${err?.code || ''} ${err?.message || err || ''}`;
return err?.code === -32601
|| /goals feature is disabled|unsupported remote app-server request|method not found|unknown mock method/i.test(detail);
}
async function handleCodexAppGoalSlashCommand(ws, text, session) {
const command = parseCodexGoalCommand(text);
if (!command) return;
if (!session) {
wsSend(ws, { type: 'system_message', message: '请先进入一个 Codex App 会话后再执行 /goal。' });
return;
}
if (!isCodexAppSession(session)) {
wsSend(ws, { type: 'system_message', message: '当前 /goal 仅支持 Codex App 会话。旧 Codex/Claude 会话没有 app-server goal RPC。' });
return;
}
if (command.error) {
wsSend(ws, { type: 'system_message', sessionId: session.id, message: command.error });
return;
}
try {
const { client, threadId } = await ensureCodexAppGoalThread(session);
if (command.action === 'show') {
const response = await client.request('thread/goal/get', { threadId }, 30000);
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
wsSend(ws, {
type: 'system_message',
sessionId: session.id,
message: goal ? formatCodexGoalUsage(goal) : '用法: /goal <目标描述>',
});
sendSessionList(ws);
return;
}
if (command.action === 'clear') {
const response = await client.request('thread/goal/clear', { threadId }, 30000);
wsSend(ws, {
type: 'system_message',
sessionId: session.id,
message: response?.cleared ? 'Goal cleared' : 'No goal to clear',
});
sendSessionList(ws);
return;
}
const response = await client.request('thread/goal/set', {
threadId,
...(command.action === 'set' ? { objective: command.objective } : {}),
status: command.action === 'pause' ? 'paused' : 'active',
}, 30000);
const goal = normalizeCodexThreadGoal(response?.goal, threadId);
wsSend(ws, {
type: 'system_message',
sessionId: session.id,
message: goal ? formatCodexGoalUsage(goal) : 'Goal updated',
});
sendSessionList(ws);
} catch (err) {
const message = isCodexGoalUnsupportedError(err)
? '当前 Codex app-server 不支持 /goal请升级 Codex 或启用 goals feature。'
: `Goal failed: ${err?.message || err}`;
wsSend(ws, { type: 'system_message', sessionId: session.id, message });
}
}
// === Slash Command Handler ===
function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
const parts = text.split(/\s+/);
@@ -4589,6 +4925,17 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
break;
}
case '/goal': {
handleCodexAppGoalSlashCommand(ws, text, session).catch((err) => {
wsSend(ws, {
type: 'system_message',
sessionId: session?.id,
message: `Goal failed: ${err?.message || err}`,
});
});
break;
}
case '/compact': {
if (!sessionId || !session) {
wsSend(ws, { type: 'system_message', message: '当前没有可压缩的会话。请先进入一个已进行过对话的会话后再执行 /compact。' });
@@ -4670,7 +5017,7 @@ function handleSlashCommand(ws, text, sessionId, fallbackAgent) {
wsSend(ws, {
type: 'system_message',
message: codexLikeAgent
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
? base + `\n/model [名称] — 查看/切换 ${agent === 'codexapp' ? 'Codex App' : 'Codex'} 模型(自由输入)${agent === 'codexapp' ? '\n/goal [目标] — 设置/查看持久目标;支持 pause/resume/clear' : ''}\n/init — 分析项目并生成/更新 AGENTS.md${agent === 'codexapp' ? '\n/compact — Codex App 模式暂不支持' : '\n/compact — 执行 Codex /compact 压缩上下文'}`
: base + '\n/model [名称] — 查看/切换模型opus, sonnet, haiku\n/compact — 执行 Claude 原生上下文压缩(保留压缩计划并可自动续跑)\n/init — 分析项目并生成/更新 CLAUDE.md',
});
break;
@@ -6145,6 +6492,200 @@ function resolvePendingCodexAppUserInputsForSession(sessionId) {
}
}
function codexAppRecord(value) {
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
}
function codexAppString(value) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function codexAppApprovalToolName(params = {}) {
const record = codexAppRecord(params);
return codexAppString(record.toolName)
|| codexAppString(record.tool_name)
|| codexAppString(record.tool)
|| codexAppString(record.name)
|| codexAppString(record.permission)
|| 'CodexTool';
}
function codexAppApprovalPreview(method, params = {}) {
const record = codexAppRecord(params);
const itemId = codexAppString(record.itemId) || codexAppString(record.item_id);
const reason = codexAppString(record.reason) || codexAppString(record.message);
const cwd = codexAppString(record.cwd);
if (method === 'item/commandExecution/requestApproval') {
const command = record.command ?? record.cmd ?? '';
return {
itemId,
approvalType: 'command',
title: 'Codex App 请求执行命令',
reason,
summary: codexAppString(command) || reason || cwd,
payload: truncateObj({ command, cwd, reason }, 4000),
allowSessionScope: true,
};
}
if (method === 'item/fileChange/requestApproval') {
const grantRoot = codexAppString(record.grantRoot) || codexAppString(record.path);
return {
itemId,
approvalType: 'file_change',
title: 'Codex App 请求修改文件',
reason,
summary: grantRoot || reason || cwd,
payload: truncateObj({ grantRoot, cwd, reason, changes: record.changes || null }, 4000),
allowSessionScope: true,
};
}
if (method === 'item/permissions/requestApproval') {
return {
itemId,
approvalType: 'permissions',
title: 'Codex App 请求提升权限',
reason,
summary: reason || cwd || '权限配置请求',
payload: truncateObj({ cwd, permissions: record.permissions || {} }, 4000),
allowSessionScope: true,
};
}
const toolName = codexAppApprovalToolName(record);
const input = record.input ?? record.arguments ?? record.params ?? record;
return {
itemId,
approvalType: 'tool',
title: `Codex App 请求调用 ${toolName}`,
reason,
summary: reason || toolName,
payload: truncateObj(input, 4000),
allowSessionScope: true,
toolName,
};
}
function codexAppApprovalDecisionFromAction(action) {
switch (String(action || '').trim()) {
case 'approve':
return 'approved';
case 'approve_session':
return 'approved_for_session';
case 'deny':
return 'denied';
default:
return 'abort';
}
}
function codexAppDecisionResponse(decision) {
switch (decision) {
case 'approved':
return { decision: 'accept' };
case 'approved_for_session':
return { decision: 'acceptForSession' };
case 'denied':
return { decision: 'decline' };
default:
return { decision: 'cancel' };
}
}
function codexAppPermissionsResponse(params = {}, decision = 'abort') {
if (decision === 'approved' || decision === 'approved_for_session') {
return {
permissions: codexAppRecord(params).permissions || {},
scope: decision === 'approved_for_session' ? 'session' : 'turn',
};
}
return {
permissions: {
network: null,
fileSystem: null,
},
scope: 'turn',
};
}
function codexAppApprovalResponse(method, params = {}, action = 'cancel') {
const decision = codexAppApprovalDecisionFromAction(action);
if (method === 'item/permissions/requestApproval') {
return codexAppPermissionsResponse(params, decision);
}
return codexAppDecisionResponse(decision);
}
function requestCodexAppApproval(routed, method, params = {}) {
const targetWs = routed?.entry?.ws || findViewingSessionWs(routed?.sessionId);
if (!routed?.sessionId || !targetWs) {
return Promise.resolve(codexAppApprovalResponse(method, params, 'cancel'));
}
const requestId = crypto.randomUUID();
const preview = codexAppApprovalPreview(method, params);
return new Promise((resolve) => {
const timer = setTimeout(() => {
pendingCodexAppApprovals.delete(requestId);
resolve(codexAppApprovalResponse(method, params, 'cancel'));
}, 10 * 60 * 1000);
pendingCodexAppApprovals.set(requestId, {
sessionId: routed.sessionId,
method,
params,
resolve,
timer,
});
wsSend(targetWs, {
type: 'codex_app_approval_request',
sessionId: routed.sessionId,
requestId,
method,
...preview,
});
});
}
function handleCodexAppApprovalResponse(ws, msg = {}) {
const requestId = String(msg.requestId || '').trim();
const pending = pendingCodexAppApprovals.get(requestId);
if (!pending) {
wsSend(ws, { type: 'error', code: 'codexapp_approval_not_found', message: 'Codex App 审批请求不存在或已超时。' });
return;
}
pendingCodexAppApprovals.delete(requestId);
clearTimeout(pending.timer);
const action = String(msg.action || 'cancel').trim();
const result = codexAppApprovalResponse(pending.method, pending.params, action);
const approved = action === 'approve' || action === 'approve_session';
const message = approved
? (action === 'approve_session' ? '已批准 Codex App 本会话执行。' : '已批准 Codex App 本次执行。')
: (action === 'deny' ? '已拒绝 Codex App 执行请求。' : '已取消 Codex App 审批请求。');
wsSend(ws, {
type: 'system_message',
sessionId: pending.sessionId,
tone: approved ? 'info' : 'warning',
transient: true,
autoDismissMs: 5000,
message,
});
pending.resolve(result);
}
function resolvePendingCodexAppApprovalsForSession(sessionId) {
for (const [requestId, pending] of pendingCodexAppApprovals) {
if (pending.sessionId !== sessionId) continue;
pendingCodexAppApprovals.delete(requestId);
clearTimeout(pending.timer);
pending.resolve(codexAppApprovalResponse(pending.method, pending.params, 'cancel'));
}
}
function handleCodexAppServerRequest(request) {
const method = request?.method || '';
const params = request?.params || {};
@@ -6152,21 +6693,24 @@ function handleCodexAppServerRequest(request) {
const dynamicToolResponse = method === 'item/tool/call'
? handleCodexAppDynamicToolCall(routed, params)
: null;
if (!dynamicToolResponse && method !== 'item/tool/requestUserInput' && routed?.entry?.ws) {
const isApprovalRequest = method === 'item/commandExecution/requestApproval'
|| method === 'item/fileChange/requestApproval'
|| method === 'item/permissions/requestApproval'
|| method === 'item/tool/requestApproval';
if (!dynamicToolResponse && !isApprovalRequest && method !== 'item/tool/requestUserInput' && routed?.entry?.ws) {
wsSend(routed.entry.ws, {
type: 'system_message',
sessionId: routed.sessionId,
message: `Codex App 请求客户端处理 ${method}当前 cc-web 暂未接入交互式审批,已按保守策略拒绝。`,
message: `Codex App 请求客户端处理 ${method}cc-web 暂不支持该请求类型,已按保守策略拒绝。`,
});
}
switch (method) {
case 'item/commandExecution/requestApproval':
return { decision: 'cancel' };
case 'item/fileChange/requestApproval':
return { decision: 'cancel' };
case 'item/permissions/requestApproval':
return { permissions: {}, scope: 'turn' };
case 'item/tool/requestApproval':
return requestCodexAppApproval(routed, method, params);
case 'item/tool/call':
if (dynamicToolResponse) return dynamicToolResponse;
return {
@@ -6217,7 +6761,7 @@ function codexAppPermissionParams(session) {
return { approvalPolicy: 'never', sandbox: 'read-only' };
}
if (mode === 'default') {
return { approvalPolicy: 'never', sandbox: 'workspace-write' };
return { approvalPolicy: 'on-request', sandbox: 'workspace-write' };
}
return { approvalPolicy: 'never', sandbox: 'danger-full-access' };
}
@@ -6229,7 +6773,7 @@ function codexAppTurnPermissionParams(session) {
}
if (mode === 'default') {
return {
approvalPolicy: 'never',
approvalPolicy: 'on-request',
sandboxPolicy: {
type: 'workspaceWrite',
writableRoots: [],
@@ -6500,6 +7044,7 @@ function handleCodexAppTurnComplete(sessionId, options = {}) {
const entry = activeCodexAppTurns.get(sessionId);
if (!entry) return;
resolvePendingCodexAppUserInputsForSession(sessionId);
resolvePendingCodexAppApprovalsForSession(sessionId);
const explicitError = options.error || null;
const rawError = explicitError || entry.lastError || null;