diff --git a/.gitignore b/.gitignore index a8a730a..43c7b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +__pycache__/ sessions/ logs/ attachments/ diff --git a/config/cross-conversation-replies.json b/config/cross-conversation-replies.json index 57733b3..6e823b1 100644 --- a/config/cross-conversation-replies.json +++ b/config/cross-conversation-replies.json @@ -1,5 +1,5 @@ { "version": 1, - "updatedAt": "2026-06-28T07:29:33.938Z", + "updatedAt": "2026-06-30T15:42:26.421Z", "replies": [] } \ No newline at end of file diff --git a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz index 64a2a26..e9688b8 100644 Binary files a/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz and b/dist-exe/cc-web-bun-linux-x64-baseline.tar.gz differ diff --git a/findings.md b/findings.md new file mode 100644 index 0000000..9385cf9 --- /dev/null +++ b/findings.md @@ -0,0 +1,72 @@ +# cc-web Codex hooks 验证发现 + +## Initial Position + +当前需验证的核心边界: + +- Codex hooks 官方上属于 Codex/app-server 的生命周期能力。 +- cc-web 作为平台客户端,理论上不应硬编码执行某个 skill 的 hooks。 +- 需要分别验证官方文档、cc-web 实现、本地环境三个层面,避免把“配置发现”“运行时注入”“技能行为”混成一个问题。 + +## Findings + +### 官方文档背景线程 + +- Codex hooks 默认开启,可用 `[features] hooks = false` 关闭。 +- hooks 从 active config layers 旁边的 `hooks.json` 或 `config.toml` 内联 `[hooks]` 发现。 +- 常见位置包括 `~/.codex/hooks.json`、`~/.codex/config.toml`、`/.codex/hooks.json`、`/.codex/config.toml`。 +- 项目级 hooks 依赖项目 `.codex/` layer trust;未信任项目时仍可加载用户/系统 hooks。 +- 非 managed command hooks 需要 review/trust;定义变化后按 hash 重新 review。 +- 未找到官方文档支持 `thread/start.config.hooks.*` 作为运行时 hooks 注入机制。 +- `externalAgentConfig/import` 的 `HOOKS` 是 external-agent artifacts 迁移导入,不是每轮运行时 hook 注入。 + +### 六项断点阶段结论 + +- 断点 1 `CODEX_HOME/HOME`:当前 local 模式下通。运行中 app-server 看到 `HOME=/home/hdzx`,`CODEX_HOME` 未设置,能按默认读取 `/home/hdzx/.codex`。custom 模式会把 `CODEX_HOME` 指向 `config/codex-runtime-home`,但当前未启用。 +- 断点 2 `thread cwd`:通。目标会话 `00a7cbc2-d0c3-457f-a262-aa5a5859fa54` 的 `cwd=/home/cc-web`;`thread/start` 和 `thread/resume` 都使用同一个 `threadParams.cwd`。 +- 断点 3 项目 `.codex` trust:通。`/home/hdzx/.codex/config.toml` 存在 `[projects."/home/cc-web"] trust_level = "trusted"`。 +- 断点 5 `[features].hooks`:未发现关闭。用户级和项目级配置、cc-web thread config、app-server 启动参数均未发现 `hooks = false`。 +- 断点 4 command hook review/trust:不通。app-server 只读 `hooks/list` 返回当前 `/home/cc-web/.codex/hooks.json` 的 7 条 command hook 全部 `trustStatus: "untrusted"`。 +- 断点 6 marker hook:不再需要作为前置验证。既然 `hooks/list` 已确认 command hooks 未 trust,按官方语义这些非 managed command hooks 会被跳过;marker 只有在 trust 后仍不触发时才需要。 + +### Command Hook Trust 明细 + +当前项目 7 条 hook 均来自 `/home/cc-web/.codex/hooks.json`,均 `enabled: true`、`source: project`,但均 `trustStatus: "untrusted"`: + +- `preToolUse`: `sha256:a3f36079079ee92b6d39f0ca263b0d23fadafdd4bd2eae8ef3982af4e446fd8b` +- `permissionRequest`: `sha256:db03c3a6c225bdb10010a5b2101d8fcb986d1ee5b2838746d8f003e952d2d08e` +- `postToolUse`: `sha256:7e6a12765c74be11e44fcfcb8f4e6534f138913fc76c539993f62b6e03da6e7a` +- `preCompact`: `sha256:73ec586017f031e8b216256e2eab41a7de8b45395643ce4461c76f212ad9a0ca` +- `sessionStart`: `sha256:b7e002f1913670e919eb42947a8b5dd7c5a9b448b07d1b0aa86bcded841109c0` +- `userPromptSubmit`: `sha256:cd4a71b110e2fdeae8eb47b47bae3e4786baa20b841f35032267cf6914567409` +- `stop`: `sha256:cd543e4852e7bc63704908c599ba9cf0bfc6662b7bd8fe2fea7738cd59d641b7` + +### cc-web Codex App 接入链路线程 + +- 当前 `local` 模式下,cc-web 不主动阻断 Codex App hooks 加载:app-server 继承 `process.env`,不剥离 `HOME` / `CODEX_HOME`;`thread/start` 和 `thread/resume` 都传入会话 `cwd`。 +- `codexAppThreadConfig()` 当前只组装 `mcp_servers.*`,没有 hooks/trust 注入;这符合“不走未文档化 `thread/start.config.hooks.*` 主路径”的判断。 +- 当前本机 `config/codex.json` 为 `mode: "local"`,所以 `custom` 模式的 `CODEX_HOME` 隔离风险未激活。 +- 风险:`custom` 模式会将 `CODEX_HOME` 指到 `config/codex-runtime-home`,该目录目前只写认证/model provider 配置,不复制 hooks 配置;如果用户级 hooks 依赖 `~/.codex/hooks.json`,custom 模式可能导致用户级 hooks 不加载。 +- cc-web 当前没有 Codex App hooks/trust 诊断 UI 或日志;已有日志能看到 app-server 初始化、collaborationMode、MCP startup,但不能直接展示 hook 是否发现、是否跳过、trustStatus 是什么。 +- 建议:增加只读 hooks 诊断能力,展示 app-server `hooks/list` 的 source/sourcePath/currentHash/trustStatus;custom 模式增加 `CODEX_HOME` 隔离提示或继承策略。 + +### hapi 对齐线程 + +- hapi 的 app-server 路径主要依赖 JSON-RPC 事件流、`thread/start.config["mcp_servers.hapi"]` 和 `turn/start.collaborationMode`,未看到 app-server 路径主动注入 hooks。 +- hapi 的 hooks 主要用于本地 Codex CLI 路径,通过 `-c hooks.SessionStart=...` 做 transcript/session 发现;这不是 app-server `thread/start.config.hooks.*` 机制。 +- hapi 因此不能作为“cc-web 应该把 hooks 注入 thread/start”的证据;它反而支持:MCP 走 `thread/start.config.mcp_servers.*`,hooks 不走未文档化的 runtime 注入。 +- hapi 也不能推翻官方 hooks 机制或本机 app-server `hooks/list` 结果;它只能说明 hapi 当前产品路径没有依赖 app-server hooks。 +- 对 cc-web 的结论应拆开: + - 运行 hooks 的主权仍属于 Codex/app-server 原生 hooks 机制。 + - cc-web 不应模拟执行 hooks,也不应发明 `thread/start.config.hooks.*` 主路径。 + - cc-web 应补的是诊断/信任入口:展示 app-server `hooks/list` 的 trust 状态,并引导用户 review/trust。 + +### 本地 planning-with-files hooks 条件线程 + +- 本地 `planning-with-files` 脚本和配置本身可用:`.codex/hooks.json` 配置了 `SessionStart`、`UserPromptSubmit`、`PreToolUse`、`PermissionRequest`、`PostToolUse`、`PreCompact`、`Stop`。 +- 当前项目根存在 `task_plan.md`、`findings.md`、`progress.md`,因此当前时刻满足 root active plan 条件;但这些文件是本轮验证中新建的,不能倒推出目标会话发生时也存在 active plan。 +- 手动 smoke test 通过:`sh .codex/hooks/user-prompt-submit.sh` 能输出 `[planning-with-files] ACTIVE PLAN`;`pre_tool_use.py` / `post_tool_use.py` adapter 能输出预期 JSON 或 progress 提醒。 +- 无 active plan 时 hook 会静默;`.planning/sessions/` 存在但当前 session 未 attached 时也会静默。 +- 多数 `.sh` hook 脚本没有 `+x`,但 `.codex/hooks.json` 使用 `sh script` / `python3 script` 调用,因此执行位不是当前阻断点。 +- 与断点 4 合并后的结论:本地脚本没坏,当前阻断点在 Codex command hook trust,7 条项目 command hook 均为 `untrusted`,所以正常 app-server 路径会跳过它们。 +- marker 方案仍可用于 trust 后二次验证;但在 hooks 未 trust 前,marker 不出现只能证明 trust 拦截,不再是定位根因的必要步骤。 diff --git a/lib/codex-rollouts.js b/lib/codex-rollouts.js index a280eae..6417c27 100644 --- a/lib/codex-rollouts.js +++ b/lib/codex-rollouts.js @@ -170,13 +170,14 @@ function createCodexRolloutStore(deps) { return walkFiles(codexSessionsDir, []).filter((filePath) => filePath.endsWith('.jsonl')).sort().reverse(); } - function getImportedCodexThreadIds() { + function getImportedCodexThreadIds(agent = 'codex') { + const field = agent === 'codexapp' ? 'codexAppThreadId' : 'codexThreadId'; const imported = new Set(); try { for (const f of fs.readdirSync(sessionsDir).filter((name) => name.endsWith('.json'))) { try { const session = normalizeSession(JSON.parse(fs.readFileSync(path.join(sessionsDir, f), 'utf8'))); - if (session.codexThreadId) imported.add(session.codexThreadId); + if (session[field]) imported.add(session[field]); } catch {} } } catch {} diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..114e625 --- /dev/null +++ b/progress.md @@ -0,0 +1,20 @@ +# cc-web Codex hooks 验证进度 + +## Log + +- 2026-06-30T00:00:00+08:00 使用 `planning-with-files` 建立本次验证计划。 +- 2026-06-30T00:00:00+08:00 确认根目录此前没有 `task_plan.md`、`findings.md`、`progress.md`;工作区存在用户已有未提交改动,主线程不会触碰。 +- 2026-06-30T00:00:00+08:00 先创建 4 个背景子线程:官方文档、cc-web 链路、hapi 对齐、本地 hooks 条件。 +- 2026-06-30T00:00:00+08:00 根据用户纠正,收敛到 6 个精准断点,并分别创建子线程验证。 +- 2026-06-30T00:00:00+08:00 官方文档背景线程已返回:确认 hooks 属于官方 config layer 发现机制,未确认 `thread/start.config.hooks.*` 是稳定注入路径。 +- 2026-06-30T00:00:00+08:00 断点 1、2、3、5 已有子线程/主线程证据:当前通或未关闭;断点 4、6 仍是关键未闭环点。 +- 2026-06-30T00:00:00+08:00 断点 4 已返回直接证据:app-server `hooks/list` 显示项目 7 条 command hook 全部 untrusted。marker 验证不再是定位根因的必要步骤。 +- 2026-06-30T00:00:00+08:00 cc-web 接入链路线程已纳入:local 模式不阻断 hooks;custom 模式存在 CODEX_HOME 隔离风险;当前缺少 hooks/trust 诊断入口。 +- 2026-06-30T00:00:00+08:00 hapi 对齐线程已纳入:hapi app-server 路径没用 hooks 注入,支持“不走 thread/start.config.hooks.*”;但不能否定 Codex 原生 hooks/list/trust 机制。 +- 2026-06-30T00:00:00+08:00 本地 planning-with-files 条件线程已纳入:脚本和 active-plan 检测本身可用;当前阻断点仍是 command hook untrusted。 +- 2026-06-30T00:00:00+08:00 断点 3 返回后完成总线收敛:项目根 `/home/cc-web` 已 trusted,但这不等于 `.codex/hooks.json` 里的每条 command hook 已通过 hash trust。最终结论锁定为 command hook trust 阻断。 +- 2026-06-30T00:00:00+08:00 断点 4 正式返回并确认:app-server 只读 `hooks/list` 对当前 `/home/cc-web/.codex/hooks.json` 返回 7 条 command hook,全部 `enabled=true`、`source=project`、`trustStatus=untrusted`。这是本轮最直接根因证据。 +- 2026-06-30T00:00:00+08:00 断点 5 正式返回并确认:用户级 `[features]` 未设置 `hooks=false`,项目 `.codex/config.toml` 无 `[features]`,cc-web thread config/app-server 参数/环境变量均未发现关闭 hooks 的配置。hooks feature 开关不是阻断点。 +- 2026-06-30T00:00:00+08:00 断点 6 隔离 marker 验证返回:临时 app-server 能通过 `hooks/list` 发现 `/tmp` 项目 hook,状态为 `enabled=true/source=project/trustStatus=untrusted`;`turn/start` 可触发但 marker 不出现、无 hook started/completed 通知。结论是“加载但未 trust”,不是“未加载 hooks”。 +- 2026-06-30T00:00:00+08:00 修复入口核验:本机 Codex CLI 0.140.0 帮助中没有公开 `hooks trust` 子命令,只有 `--dangerously-bypass-hook-trust`;app-server schema 暴露 `hooks/list` 和 `HookTrustStatus`,未发现公开 `hooks/trust` 请求。产品修复第一阶段应先做诊断/引导,不直接发明 trust 写入。 +- 2026-06-30T00:00:00+08:00 用户执行 Codex hook review 后复核:`/home/hdzx/.codex/config.toml` 新增 `[hooks.state]` 下 7 条 `/home/cc-web/.codex/hooks.json::0:0` 的 `trusted_hash`;按 schema 用 `hooks/list { cwds: ["/home/cc-web"] }` 复查,7 条 project hooks 均返回 `trustStatus=trusted`。 diff --git a/public/app.js b/public/app.js index 9f6cbfa..1be3710 100644 --- a/public/app.js +++ b/public/app.js @@ -4057,13 +4057,10 @@ }); } if (importSessionBtn) { - if (isCodexAppAgent(currentAgent)) { - importSessionBtn.textContent = 'Codex App 暂不支持导入'; - importSessionBtn.disabled = true; - } else { - importSessionBtn.textContent = currentAgent === 'codex' ? '导入本地 Codex 会话' : '导入本地 Claude 会话'; - importSessionBtn.disabled = false; - } + importSessionBtn.textContent = isCodexLikeAgent(currentAgent) + ? `导入本地 ${AGENT_LABELS[currentAgent]} 会话` + : '导入本地 Claude 会话'; + importSessionBtn.disabled = false; } updateReloadMcpButtonUI(); } @@ -8297,9 +8294,7 @@ }); importSessionBtn.addEventListener('click', () => { newChatDropdown.hidden = true; - if (isCodexAppAgent(currentAgent)) { - appendError('Codex App 模式暂不支持导入本地会话。'); - } else if (currentAgent === 'codex') { + if (isCodexLikeAgent(currentAgent)) { showImportCodexSessionModal(); } else { showImportSessionModal(); @@ -9970,7 +9965,15 @@ } function showImportCodexSessionModal() { - if (currentAgent !== 'codex') return; + if (!isCodexLikeAgent(currentAgent)) return; + const importAgent = currentAgent; + const label = AGENT_LABELS[importAgent] || 'Codex'; + const contextTitle = importAgent === 'codexapp' + ? '从 Codex App rollout 历史导入' + : '从 Codex rollout 历史导入'; + const contextCopy = importAgent === 'codexapp' + ? '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复对话文本、工具调用和 token 统计,并绑定 Codex App 线程用于后续续接。' + : '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。'; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.id = 'import-codex-session-overlay'; @@ -9978,12 +9981,12 @@ overlay.innerHTML = ` `; @@ -10002,11 +10005,11 @@ const body = overlay.querySelector('#ics-body'); if (!body) return; if (!items || items.length === 0) { - body.innerHTML = `${buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。')}`; + body.innerHTML = `${buildAgentContextCard(importAgent, contextTitle, contextCopy)}`; return; } - body.innerHTML = buildAgentContextCard('codex', '从 Codex rollout 历史导入', '读取 ~/.codex/sessions/ 下的 rollout 文件,恢复用户消息、助手输出、函数调用和 token 统计。'); + body.innerHTML = buildAgentContextCard(importAgent, contextTitle, contextCopy); items.forEach((sess) => { const item = document.createElement('div'); item.className = 'import-item'; @@ -10050,11 +10053,11 @@ btn.textContent = sess.alreadyImported ? '重新导入' : '导入'; btn.addEventListener('click', () => { const confirmed = sess.alreadyImported - ? confirm('已导入过此 Codex 会话,重新导入将覆盖已有内容。确认继续?') - : confirm('将解析本地 Codex rollout 历史并导入当前 Web 视图。确认继续?'); + ? confirm(`已导入过此 ${label} 会话,重新导入将覆盖已有内容。确认继续?`) + : confirm(`将解析本地 ${label} rollout 历史并导入当前 Web 视图。确认继续?`); if (!confirmed) return; close(); - send({ type: 'import_codex_session', threadId: sess.threadId, rolloutPath: sess.rolloutPath }); + send({ type: 'import_codex_session', agent: importAgent, threadId: sess.threadId, rolloutPath: sess.rolloutPath }); }); item.appendChild(info); @@ -10063,7 +10066,7 @@ }); }; - send({ type: 'list_codex_sessions' }); + send({ type: 'list_codex_sessions', agent: importAgent }); } // --- Helpers --- diff --git a/scripts/regression.js b/scripts/regression.js index 5b328a4..10e402c 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -313,16 +313,22 @@ function createFakeClaudeHistory(homeDir) { return { sessionId, projectDir: 'tmp-project', filePath }; } -function createFakeCodexHistory(homeDir) { +function createFakeCodexHistory(homeDir, options = {}) { const sessionsDir = path.join(homeDir, '.codex', 'sessions', '2026', '03', '12'); mkdirp(sessionsDir); - const threadId = 'codex-import-thread'; - const rolloutPath = path.join(sessionsDir, 'rollout-2026-03-12T00-00-00-codex-import-thread.jsonl'); + const threadId = options.threadId || 'codex-import-thread'; + const cwd = options.cwd || '/tmp/project-b'; + const userText = options.userText || 'Codex import prompt'; + const answerText = options.answerText || 'Codex import answer'; + const source = options.source || 'exec'; + const cliVersion = options.cliVersion || '0.114.0'; + const fileStamp = options.fileStamp || '2026-03-12T00-00-00'; + const rolloutPath = path.join(sessionsDir, `rollout-${fileStamp}-${threadId}.jsonl`); const rolloutLines = [ JSON.stringify({ timestamp: '2026-03-12T00:00:00.000Z', type: 'session_meta', - payload: { id: threadId, cwd: '/tmp/project-b', cli_version: '0.114.0', source: 'exec' }, + payload: { id: threadId, cwd, cli_version: cliVersion, source }, }), JSON.stringify({ timestamp: '2026-03-12T00:00:00.100Z', @@ -336,7 +342,7 @@ function createFakeCodexHistory(homeDir) { JSON.stringify({ timestamp: '2026-03-12T00:00:01.000Z', type: 'event_msg', - payload: { type: 'user_message', message: 'Codex import prompt' }, + payload: { type: 'user_message', message: userText }, }), JSON.stringify({ timestamp: '2026-03-12T00:00:02.000Z', @@ -344,7 +350,7 @@ function createFakeCodexHistory(homeDir) { payload: { type: 'message', role: 'assistant', - content: [{ type: 'output_text', text: 'Codex import answer' }], + content: [{ type: 'output_text', text: answerText }], }, }), JSON.stringify({ @@ -419,7 +425,7 @@ function createFakeCodexHistory(homeDir) { estimated_bytes INTEGER NOT NULL DEFAULT 0 ); INSERT INTO threads (id, rollout_path, created_at, updated_at, source, model_provider, cwd, title, sandbox_policy, approval_mode, cli_version) - VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, 'exec', 'OpenAI', '/tmp/project-b', 'Codex import prompt', '{}', 'never', '0.114.0'); + VALUES ('${threadId}', '${rolloutPath.replace(/'/g, "''")}', 1, 2, '${source}', 'OpenAI', '${cwd.replace(/'/g, "''")}', '${userText.replace(/'/g, "''")}', '{}', 'never', '${cliVersion}'); INSERT INTO logs (ts, ts_nanos, level, target, thread_id) VALUES (1, 0, 'INFO', 'test', '${threadId}'); `); @@ -500,6 +506,15 @@ function assertFrontendGenerationControlsContract() { !source.includes(staleDefaultApprovalWarning), 'Frontend should not show the stale default-mode approval warning after Codex App approvals are supported' ); + assert( + !source.includes('Codex App 暂不支持导入') && !source.includes('Codex App 模式暂不支持导入'), + 'Frontend should not disable Codex App native session import' + ); + assert( + source.includes("send({ type: 'list_codex_sessions', agent: importAgent })") && + source.includes("send({ type: 'import_codex_session', agent: importAgent"), + 'Frontend Codex import modal should pass the selected Codex-like agent' + ); } function assertFrontendComposerMcpContract() { @@ -674,6 +689,14 @@ async function main() { createFakeClaudeHistory(homeDir); createFakeCodexConfig(homeDir); const codexFixture = createFakeCodexHistory(homeDir); + const codexAppImportFixture = createFakeCodexHistory(homeDir, { + threadId: 'codexapp-import-thread', + cwd: '/tmp/project-c', + userText: 'Codex App import prompt', + answerText: 'Codex App import answer', + source: 'vscode', + fileStamp: '2026-03-12T00-00-10', + }); const port = await getFreePort(); const password = 'Regression!234'; @@ -1870,6 +1893,48 @@ async function main() { assert(importedCodex.messages?.[0]?.content === 'Codex import prompt', 'Codex import kept wrapper instructions'); assert(importedCodex.totalUsage?.inputTokens === 20, 'Codex import usage parse failed'); + ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' })); + const codexAppImportSessions = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions'); + const codexAppImportItem = codexAppImportSessions.sessions.find((item) => item.threadId === codexAppImportFixture.threadId); + assert(codexAppImportItem, 'Codex App session listing failed'); + assert(codexAppImportItem.agent === 'codexapp', 'Codex App import listing should echo target agent'); + assert(codexAppImportItem.alreadyImported === false, 'Codex App import should not reuse old Codex imported state'); + + ws.send(JSON.stringify({ + type: 'import_codex_session', + agent: 'codexapp', + threadId: codexAppImportItem.threadId, + rolloutPath: codexAppImportItem.rolloutPath, + })); + const importedCodexApp = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.title === 'Codex App import prompt'); + assert(importedCodexApp.messages?.[0]?.content === 'Codex App import prompt', 'Codex App import parsed wrong first message'); + assert(importedCodexApp.totalUsage?.inputTokens === 20, 'Codex App import usage parse failed'); + const importedCodexAppPath = path.join(sessionsDir, `${importedCodexApp.sessionId}.json`); + const storedImportedCodexApp = JSON.parse(fs.readFileSync(importedCodexAppPath, 'utf8')); + assert(storedImportedCodexApp.agent === 'codexapp', 'Codex App import should persist codexapp agent'); + assert(storedImportedCodexApp.codexAppThreadId === codexAppImportFixture.threadId, 'Codex App import should persist codexAppThreadId'); + assert(!storedImportedCodexApp.codexThreadId, 'Codex App import should not persist legacy codexThreadId'); + + ws.send(JSON.stringify({ type: 'list_codex_sessions', agent: 'codexapp' })); + const codexAppImportSessionsAfter = await nextMessage(messages, ws, (msg) => msg.type === 'codex_sessions'); + const codexAppImportItemAfter = codexAppImportSessionsAfter.sessions.find((item) => item.threadId === codexAppImportFixture.threadId); + assert(codexAppImportItemAfter?.alreadyImported === true, 'Codex App import listing should mark codexAppThreadId as imported'); + + ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedCodexApp.sessionId })); + await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedCodexApp.sessionId)); + assert(!fs.existsSync(importedCodexAppPath), 'Deleting Codex App imported session did not remove cc-web session JSON'); + assert(fs.existsSync(codexAppImportFixture.rolloutPath), 'Deleting Codex App imported session should keep rollout history for recovery'); + + ws.send(JSON.stringify({ + type: 'import_codex_session', + agent: 'codexapp', + threadId: codexAppImportFixture.threadId, + rolloutPath: codexAppImportFixture.rolloutPath, + })); + const restoredCodexApp = await nextMessage(messages, ws, (msg) => msg.type === 'session_info' && msg.agent === 'codexapp' && msg.title === 'Codex App import prompt'); + assert(restoredCodexApp.sessionId !== importedCodexApp.sessionId, 'Codex App deleted session should be recreated from rollout history'); + assert(restoredCodexApp.messages?.[0]?.content === 'Codex App import prompt', 'Codex App re-import should restore messages after cc-web deletion'); + const importedSessionId = importedCodex.sessionId; ws.send(JSON.stringify({ type: 'delete_session', sessionId: importedSessionId })); await nextMessage(messages, ws, (msg) => msg.type === 'session_list' && !msg.sessions.some((s) => s.id === importedSessionId)); diff --git a/server.js b/server.js index 46d1587..4b0086c 100644 --- a/server.js +++ b/server.js @@ -6314,7 +6314,7 @@ wss.on('connection', (ws, req) => { handleImportNativeSession(ws, msg); break; case 'list_codex_sessions': - handleListCodexSessions(ws); + handleListCodexSessions(ws, msg); break; case 'import_codex_session': handleImportCodexSession(ws, msg); @@ -9878,8 +9878,13 @@ function handleImportNativeSession(ws, msg) { sendSessionList(ws); } -function handleListCodexSessions(ws) { - const imported = getImportedCodexThreadIds(); +function resolveCodexImportAgent(value) { + return value === 'codexapp' ? 'codexapp' : 'codex'; +} + +function handleListCodexSessions(ws, msg = {}) { + const importAgent = resolveCodexImportAgent(msg?.agent); + const imported = getImportedCodexThreadIds(importAgent); const items = []; const seen = new Set(); for (const filePath of getCodexRolloutFiles()) { @@ -9896,6 +9901,7 @@ function handleListCodexSessions(ws) { cliVersion: parsed.meta.cliVersion || '', source: parsed.meta.source || '', rolloutPath: filePath, + agent: importAgent, alreadyImported: imported.has(parsed.meta.threadId), }); } @@ -9904,6 +9910,7 @@ function handleListCodexSessions(ws) { function handleImportCodexSession(ws, msg) { const threadId = String(msg?.threadId || '').trim(); + const importAgent = resolveCodexImportAgent(msg?.agent); if (!threadId) { return wsSend(ws, { type: 'error', message: '缺少 threadId' }); } @@ -9932,7 +9939,8 @@ function handleImportCodexSession(ws, msg) { for (const f of fs.readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.json'))) { try { const s = normalizeSession(JSON.parse(fs.readFileSync(path.join(SESSIONS_DIR, f), 'utf8'))); - if (s.codexThreadId === threadId) { existingSession = s; break; } + const runtimeThreadId = importAgent === 'codexapp' ? s.codexAppThreadId : s.codexThreadId; + if (runtimeThreadId === threadId) { existingSession = s; break; } } catch {} } } catch {} @@ -9944,10 +9952,11 @@ function handleImportCodexSession(ws, msg) { created: existingSession?.created || new Date().toISOString(), updated: new Date().toISOString(), pinnedAt: existingSession?.pinnedAt || null, - agent: 'codex', + agent: importAgent, claudeSessionId: null, - codexThreadId: threadId, - importedFrom: 'codex', + codexThreadId: importAgent === 'codex' ? threadId : null, + codexAppThreadId: importAgent === 'codexapp' ? threadId : null, + importedFrom: importAgent, importedRolloutPath: parsed.filePath, model: existingSession?.model || null, permissionMode: existingSession?.permissionMode || 'yolo', diff --git a/task_plan.md b/task_plan.md new file mode 100644 index 0000000..fbf3855 --- /dev/null +++ b/task_plan.md @@ -0,0 +1,43 @@ +# cc-web Codex hooks 验证计划 + +## Goal + +验证 cc-web 的 Codex App 模式下,Codex hooks 应由谁加载、当前链路是否让 app-server 发现项目/全局 hooks,以及 `planning-with-files` hook 没体现效果的真实原因。 + +## Status + +- [x] 建立验证计划 +- [x] 官方文档验证:确认 app-server/hooks 官方支持边界 +- [x] cc-web 实现链路验证:确认 cwd、CODEX_HOME、thread params、config 注入逻辑 +- [x] 本地环境/触发条件验证:确认 hook 文件、active plan、trust/feature 开关等条件 +- [x] 汇总结论:明确是否需要 cc-web 改造,以及改造点 + +## Focused Breakpoints + +- [x] app-server 进程看到的 `CODEX_HOME` / `HOME` 是否正确 +- [x] `thread/start` / `thread/resume` 的 `cwd` 是否为 `/home/cc-web` +- [x] `/home/cc-web/.codex` 项目层是否被 Codex trust +- [x] `.codex/hooks.json` 里的 command hook 是否已 review/trust +- [x] `[features].hooks` 是否被关闭 +- [x] 最小 marker hook 是否能证明 app-server 触发 hooks + +## Final Synthesis + +- 当前目标会话没看到 `planning-with-files` hook 效果,主因是项目 hooks 的 command hash 未被 Codex trust:`hooks/list` 已显示 7 条 command hook 全部 `untrusted`。 +- `HOME/CODEX_HOME`、`thread cwd`、项目根 trust、`[features].hooks` 这些前置条件在当前 local 模式下都不是阻断点。 +- `planning-with-files` 脚本本身有效;手动 smoke test 可输出 active plan。它的效果被 Codex command hook trust 层挡住,而不是 skill 逻辑坏。 +- cc-web 不应自行模拟执行 hooks,也不应把 `thread/start.config.hooks.*` 当主路径;应让 Codex app-server 原生 hooks/list/trust 机制工作,并补诊断/信任入口。 + +## Questions + +1. `thread/start.config.hooks.*` 是否有官方文档支持,还是不应作为主路径? +2. cc-web 是否应该自行执行 hooks,还是只保证 app-server 能看到官方 config layers? +3. 当前项目 hooks 没生效,更可能是哪一层断了? +4. 如何用最小可复现实验证明 app-server 端是否加载并执行 hooks? + +## Constraints + +- 不修改业务代码。 +- 不覆盖用户已有未提交改动。 +- 代码理解优先使用 `codebase-memory-mcp`,不用 graphify。 +- 官方 Codex 行为优先参考 OpenAI 官方文档。