diff --git a/.planning/codex-native-update-plan/findings.md b/.planning/codex-native-update-plan/findings.md new file mode 100644 index 0000000..b9cb9e6 --- /dev/null +++ b/.planning/codex-native-update-plan/findings.md @@ -0,0 +1,26 @@ +# 调查证据 + +## 官方版本与默认值 + +- 官方变更:2026-08-31,PR #41744「Make the update_plan tool opt-in」:https://github.com/openai/codex/pull/41744 。提交 a9519cbcdd2d664530edb2469224ee03c1056799 明确将开关缺省改为 false,并在关闭时移除内置提示词中的调用指导。 +- 本机版本:codex-cli 0.157.1。 +- 官方对应发布:https://github.com/openai/codex/releases/tag/rust-v0.157.1 ,发布时间 2026-09-26。 +- https://github.com/openai/codex/blob/rust-v0.157.1/codex-rs/core/src/config/mod.rs#L2689 :resolve_update_plan_enabled 使用 tools.update_plan.as_ref().is_some_and(|config| config.enabled),缺省为 false。 +- https://github.com/openai/codex/blob/rust-v0.157.1/codex-rs/core/src/tools/spec_plan.rs#L1104 :仅在 config.update_plan_enabled 为 true 时注册 PlanHandler。 +- 主分支仍定义名为 update_plan 的原生工具,未发现工具被改名的证据。 +- https://developers.openai.com/codex/app-server/ 仍说明 turn/plan/updated 事件。 + +## 本机实际链路 + +- 当前会话 agent=codexapp、permissionMode=yolo、model=gpt-6-astra(xhigh)。 +- app-server 使用 HOME=/home/hdzx,未设置 CODEX_HOME。 +- 用户全局配置和项目 .codex/config.toml 均未设置 tools.update_plan.enabled。 +- server.js 中 codexAppThreadConfig 未设置计划工具开关,buildCodexAppClientSpec 启动参数也未设置。 +- lib/agent-runtime.js 的 CLI 参数路径同样未设置。 +- YOLO 仅影响权限和 collaborationMode.default;没有过滤原生计划工具。 + +## 修复方向 + +- 线程配置显式设置 tools.update_plan.enabled=true,覆盖启动、恢复和分叉链路。 +- CLI 参数路径使用同一原生配置开关。 +- 在已有项目回归中核对线程和 CLI 确实传入配置开关。 diff --git a/.planning/codex-native-update-plan/progress.md b/.planning/codex-native-update-plan/progress.md new file mode 100644 index 0000000..00db255 --- /dev/null +++ b/.planning/codex-native-update-plan/progress.md @@ -0,0 +1,11 @@ +# 执行记录 + +## 2026-09-30 + +- 读取 OpenAI Docs、项目工作流和规划技能。 +- 通过 11.144.144.9:7890 成功获取官方 App Server 文档、0.157.1 源码和发布信息。 +- 并行只读核查本机实际进程、会话、全局及线程配置。 +- 确认原生 update_plan 需要显式启用,ccweb 缺少该配置。 +- 开始修复原生配置。 +- 曾尝试本地官方二进制探针,但本机独立测试上下文未暴露工具,请求断言失败,不能当作修复验收结果。 +- 用户要求停止扩大的二进制测试;已删除该探针,收敛到配置修复和已有必要回归。 diff --git a/.planning/codex-native-update-plan/task_plan.md b/.planning/codex-native-update-plan/task_plan.md new file mode 100644 index 0000000..6fcf635 --- /dev/null +++ b/.planning/codex-native-update-plan/task_plan.md @@ -0,0 +1,28 @@ +# 恢复 Codex 原生 update_plan + +## 目标 + +查明官方工具缺席原因,通过原生配置恢复 PlanList,并完成必要回归、发布和安全重启。 + +## 状态 + +1. 查证官方版本与工具注册条件:completed +2. 核对 ccweb 实际进程和会话配置:completed +3. 显式启用原生计划工具:in_progress +4. 验证配置开关和现有协议回归:pending +5. 重新打包并提交推送:pending +6. 检查运行会话并重启服务:pending + +## 约束 + +- 不修改用户全局 Codex 配置或敏感配置。 +- 按用户要求不安排额外审计。 +- 用户要求收敛到配置修复;已停止二进制探针测试。 +- 当前会话没有原生 update_plan,不能假定本轮会立即获得新工具。 +- 重启前必须确认除当前会话外没有 running 会话。 +- 保留既有 PlanList 事件渲染路径;原生工具恢复不能仅依赖 MCP 兜底。 + +## 已遇问题 + +- 官方文档直连返回 403;使用项目提供的 HTTP 代理后访问成功。 +- 主分支工具注册文件已从 spec.rs 拆为 spec_plan.rs,旧路径返回 404。 diff --git a/.trellis/tasks/09-30-codex-native-update-plan/implement.jsonl b/.trellis/tasks/09-30-codex-native-update-plan/implement.jsonl new file mode 100644 index 0000000..661268e --- /dev/null +++ b/.trellis/tasks/09-30-codex-native-update-plan/implement.jsonl @@ -0,0 +1,2 @@ +{"file":".trellis/spec/backend/quality-guidelines.md","reason":"遵循现有后端配置和回归约定"} +{"file":".planning/codex-native-update-plan/findings.md","reason":"官方默认开关与实际链路证据"} diff --git a/.trellis/tasks/09-30-codex-native-update-plan/prd.md b/.trellis/tasks/09-30-codex-native-update-plan/prd.md new file mode 100644 index 0000000..f64e961 --- /dev/null +++ b/.trellis/tasks/09-30-codex-native-update-plan/prd.md @@ -0,0 +1,23 @@ +# 恢复 Codex 原生计划工具 + +用户发现 PlanList 不显示,要求查清官方 update_plan 是否被取消或替换,修复后重新打包提交推送并重启服务;不要安排额外审计。 + +## 已确认原因 + +官方 Codex 0.157.1 的 tools.update_plan.enabled 缺省为 false;当前 ccweb 没有显式开启。YOLO 不控制该工具的注册。 + +## 实现要求 + +1. 在 server.js 的 codexAppThreadConfig 中显式开启原生计划工具,覆盖 thread/start、thread/resume 和 thread/fork。 +2. 在 lib/agent-runtime.js 的 Codex CLI 参数路径加入 -c tools.update_plan.enabled=true。 +3. 在现有回归中断言发送的配置确实包含开关;验证正常 YOLO 和恢复路径。 +4. 不更改用户全局配置,不把 MCP 兜底是否调用作为原生修复验收标准。 +5. 保留既有 PlanList 事件处理,修改范围保持在相关启动配置和回归断言。 + +## 验收 + +- 现有回归断言线程配置和 CLI 参数显式开启 update_plan。 +- 项目回归通过,发布包重新生成并提交推送。 +- 重启符合项目会话状态限制并检查服务可用性。 + +用户最新要求收敛到配置修复,停止二进制探针测试。 diff --git a/.trellis/tasks/09-30-codex-native-update-plan/task.json b/.trellis/tasks/09-30-codex-native-update-plan/task.json new file mode 100644 index 0000000..0fd48c0 --- /dev/null +++ b/.trellis/tasks/09-30-codex-native-update-plan/task.json @@ -0,0 +1,14 @@ +{ + "id": "codex-native-update-plan", + "name": "codex-native-update-plan", + "title": "恢复 Codex 原生 update_plan", + "status": "in_progress", + "priority": "P1", + "creator": "codex", + "assignee": "codex", + "createdAt": "2026-09-30", + "completedAt": null, + "base_branch": "main", + "relatedFiles": ["server.js", "lib/agent-runtime.js", "scripts/regression.js"], + "notes": "官方0.157.1缺省关闭原生计划工具;修复配置并验证实际请求。用户要求不安排额外审计。" +} diff --git a/.trellis/tasks/10-05-file-browser-image-preview/check.jsonl b/.trellis/tasks/10-05-file-browser-image-preview/check.jsonl new file mode 100644 index 0000000..3034e74 --- /dev/null +++ b/.trellis/tasks/10-05-file-browser-image-preview/check.jsonl @@ -0,0 +1,3 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端质量检查"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "服务端质量检查"} diff --git a/.trellis/tasks/10-05-file-browser-image-preview/implement.jsonl b/.trellis/tasks/10-05-file-browser-image-preview/implement.jsonl new file mode 100644 index 0000000..b59636e --- /dev/null +++ b/.trellis/tasks/10-05-file-browser-image-preview/implement.jsonl @@ -0,0 +1,3 @@ +{"_example": "Fill with {\"file\": \"\", \"reason\": \"\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."} +{"file": ".trellis/spec/frontend/quality-guidelines.md", "reason": "前端质量与资源生命周期约束"} +{"file": ".trellis/spec/backend/quality-guidelines.md", "reason": "服务端边界校验与错误处理约束"} diff --git a/.trellis/tasks/10-05-file-browser-image-preview/prd.md b/.trellis/tasks/10-05-file-browser-image-preview/prd.md new file mode 100644 index 0000000..aa267ee --- /dev/null +++ b/.trellis/tasks/10-05-file-browser-image-preview/prd.md @@ -0,0 +1,20 @@ +# 文件浏览器支持图片预览 + +## 目标 + +在 cc-web 文件浏览器中点击常见图片文件时,在现有预览面板内显示图片;文本文件预览保持不变。 + +## 范围 + +- 支持 PNG、JPEG/JPG、GIF、WebP、BMP、SVG、AVIF 图片。 +- 图片读取必须沿用 Bearer 登录态、会话工作目录根约束和现有路径穿越防护。 +- 图片响应设置正确 MIME 类型,并限制最大预览大小为 20 MiB;超出时返回明确错误。 +- 前端复用现有文件选择流程,通过带鉴权的请求取得图片。栅格图片使用临时对象 URL 展示,并在切换文件、关闭浏览器和请求过期时释放;SVG 使用隔离来源的 data URL 展示。 +- 文本预览、二进制文件拒绝和现有移动端切换行为保持不变。 + +## 验收 + +- 点击上述格式的有效图片显示在预览面板,并显示文件名、大小、格式等元信息。 +- 图片加载失败显示中文错误状态,旧图片不会残留。 +- 越界路径、未登录请求、目录路径和超大图片不能绕过现有安全限制。 +- 回归测试覆盖图片响应、MIME、大小限制、路径边界和前端关键契约。 diff --git a/.trellis/tasks/10-05-file-browser-image-preview/task.json b/.trellis/tasks/10-05-file-browser-image-preview/task.json new file mode 100644 index 0000000..e4de331 --- /dev/null +++ b/.trellis/tasks/10-05-file-browser-image-preview/task.json @@ -0,0 +1,26 @@ +{ + "id": "file-browser-image-preview", + "name": "file-browser-image-preview", + "title": "文件浏览器支持图片预览", + "description": "文件浏览器预览常见图片格式,沿用会话授权与工作目录边界。", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "shiyue", + "assignee": "shiyue", + "createdAt": "2026-10-05", + "completedAt": null, + "branch": null, + "base_branch": "main", + "worktree_path": null, + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ 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 c7b5db6..5956a97 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/public/app.js b/public/app.js index e42444b..d89322d 100644 --- a/public/app.js +++ b/public/app.js @@ -3063,6 +3063,26 @@ return data || {}; } + async function fetchAuthImage(url, signal) { + await ensureAuthenticatedWs(); + const response = await fetch(url, { + signal, + headers: { Authorization: `Bearer ${authToken}` }, + }); + if (response.status === 401) { + throw new Error('登录状态已失效,请刷新页面后重新登录。'); + } + if (!response.ok) { + const data = await response.json().catch(() => null); + throw new Error(data?.message || `请求失败 (${response.status})`); + } + const mimeType = (response.headers.get('Content-Type') || '').split(';')[0].trim().toLowerCase(); + if (!FILE_BROWSER_IMAGE_MIME_TYPES.has(mimeType)) { + throw new Error('图片响应格式不受支持'); + } + return { blob: await response.blob(), mimeType }; + } + function updateReloadMcpButtonUI() { if (!reloadMcpBtn) return; const visible = !!currentSessionId && isCodexAppAgent(currentAgent); @@ -4118,9 +4138,36 @@ loadDirectoryPickerDirectory(String(options.initialPath || '').trim(), { allowFallback: true }); } + const FILE_BROWSER_IMAGE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif']); + const FILE_BROWSER_IMAGE_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/svg+xml', 'image/avif']); + + function isFileBrowserImagePath(filePath) { + const extension = String(filePath || '').match(/\.([a-z0-9]+)$/i)?.[1]; + return !!extension && FILE_BROWSER_IMAGE_EXTENSIONS.has(extension.toLowerCase()); + } + + function clearFileBrowserImagePreview(state) { + state.previewImageNode.onload = null; + state.previewImageNode.onerror = null; + state.previewImageNode.removeAttribute('src'); + state.previewImageEl.hidden = true; + if (state.previewImageUrl) URL.revokeObjectURL(state.previewImageUrl); + state.previewImageUrl = ''; + } + + function cancelFileBrowserPreview(state) { + state.previewRequestId += 1; + state.previewAbortController?.abort(); + state.previewAbortController = null; + clearFileBrowserImagePreview(state); + return state.previewRequestId; + } + function closeFileBrowser() { if (!fileBrowserState) return; - const { overlay, escapeHandler } = fileBrowserState; + const state = fileBrowserState; + cancelFileBrowserPreview(state); + const { overlay, escapeHandler } = state; if (escapeHandler) document.removeEventListener('keydown', escapeHandler); if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay); fileBrowserState = null; @@ -4149,19 +4196,22 @@ function renderFileBrowserPreviewEmpty(title, message) { if (!fileBrowserState) return; + clearFileBrowserImagePreview(fileBrowserState); fileBrowserState.previewTitleEl.textContent = title || '文件预览'; - fileBrowserState.previewMetaEl.textContent = message || '选择一个文本文件查看内容'; - fileBrowserState.previewEmptyEl.textContent = message || '选择一个文本文件查看内容'; + fileBrowserState.previewMetaEl.textContent = message || '选择一个文件查看内容'; + fileBrowserState.previewEmptyEl.textContent = message || '选择一个文件查看内容'; fileBrowserState.previewEmptyEl.hidden = false; fileBrowserState.previewCodeEl.hidden = true; fileBrowserState.previewCodeEl.textContent = ''; } - function renderFileBrowserPreviewLoading(name) { + function renderFileBrowserPreviewLoading(name, image = false) { if (!fileBrowserState) return; + clearFileBrowserImagePreview(fileBrowserState); + const message = image ? '正在读取图片…' : '正在读取文件内容…'; fileBrowserState.previewTitleEl.textContent = name || '文件预览'; - fileBrowserState.previewMetaEl.textContent = '正在读取文件内容…'; - fileBrowserState.previewEmptyEl.textContent = '正在读取文件内容…'; + fileBrowserState.previewMetaEl.textContent = message; + fileBrowserState.previewEmptyEl.textContent = message; fileBrowserState.previewEmptyEl.hidden = false; fileBrowserState.previewCodeEl.hidden = true; fileBrowserState.previewCodeEl.textContent = ''; @@ -4190,7 +4240,7 @@ if (entry.kind === 'directory') { metaParts.push(entry.symlink ? '链接目录' : '目录'); } else { - metaParts.push(entry.previewableHint ? '文本' : '文件'); + metaParts.push(entry.imagePreviewableHint ? '图片' : (entry.previewableHint ? '文本' : '文件')); if (entry.size >= 0) metaParts.push(formatFileSize(entry.size)); } if (entry.updatedAt) metaParts.push(timeAgo(entry.updatedAt)); @@ -4200,8 +4250,9 @@ type="button" data-kind="${escapeHtml(entry.kind)}" data-path="${escapeHtml(entry.path || '')}" + data-image-previewable="${entry.imagePreviewableHint ? 'true' : 'false'}" > - + ${escapeHtml(entry.name || '')} ${escapeHtml(metaParts.join(' · '))} @@ -4217,7 +4268,7 @@ loadFileBrowserDirectory(itemPath); return; } - openFileBrowserFile(itemPath); + openFileBrowserFile(itemPath, { imagePreviewable: button.dataset.imagePreviewable === 'true' }); }); }); @@ -4235,9 +4286,10 @@ state.listEl.innerHTML = ''; setFileBrowserStatus('正在读取目录…'); if (!options.preservePreview) { + cancelFileBrowserPreview(state); state.selectedFilePath = ''; syncFileBrowserSelection(); - renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容'); + renderFileBrowserPreviewEmpty('文件预览', '选择一个文件查看内容'); setFileBrowserPreviewMode(false); } @@ -4276,15 +4328,61 @@ if (!fileBrowserState) return; const state = fileBrowserState; const normalizedPath = normalizeBrowserPath(targetPath); + const imagePreviewable = options.imagePreviewable === true + || (options.imagePreviewable == null && isFileBrowserImagePath(normalizedPath)); const targetLine = Math.max(0, Number(options.line || 0) || 0); - const requestId = ++state.previewRequestId; + const requestId = cancelFileBrowserPreview(state); + const controller = new AbortController(); + state.previewAbortController = controller; state.selectedFilePath = normalizedPath; syncFileBrowserSelection(); - renderFileBrowserPreviewLoading(normalizedPath.split('/').pop() || '文件预览'); + renderFileBrowserPreviewLoading(normalizedPath.split('/').pop() || '文件预览', imagePreviewable); setFileBrowserPreviewMode(true); try { - const data = await fetchAuthJson(`/api/fs/read?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`); + if (imagePreviewable) { + const { blob, mimeType } = await fetchAuthImage( + `/api/fs/image?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`, + controller.signal + ); + if (state !== fileBrowserState || requestId !== state.previewRequestId) return; + const imageUrl = mimeType === 'image/svg+xml' + ? await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(reader.error || new Error('图片读取失败')); + reader.readAsDataURL(blob); + }) + : URL.createObjectURL(blob); + if (state !== fileBrowserState || requestId !== state.previewRequestId) { + if (mimeType !== 'image/svg+xml') URL.revokeObjectURL(imageUrl); + return; + } + if (mimeType !== 'image/svg+xml') state.previewImageUrl = imageUrl; + const name = normalizedPath.split('/').pop() || '图片预览'; + state.previewImageNode.alt = name; + state.previewImageNode.onload = () => { + if (state !== fileBrowserState || requestId !== state.previewRequestId) return; + state.previewTitleEl.textContent = name; + state.previewMetaEl.textContent = `${formatFileSize(blob.size)} · ${mimeType}`; + state.previewEmptyEl.hidden = true; + state.previewImageEl.hidden = false; + setFileBrowserStatus(`已打开 ${name}`); + }; + state.previewImageNode.onerror = () => { + if (state !== fileBrowserState || requestId !== state.previewRequestId) return; + state.selectedFilePath = ''; + syncFileBrowserSelection(); + renderFileBrowserPreviewEmpty('无法预览', '图片加载失败'); + setFileBrowserStatus('图片加载失败', 'error'); + }; + state.previewImageNode.src = imageUrl; + return; + } + const data = await fetchAuthJson( + `/api/fs/read?sessionId=${encodeURIComponent(state.sessionId)}&path=${encodeURIComponent(normalizedPath)}`, + { signal: controller.signal } + ); if (!fileBrowserState || state !== fileBrowserState || requestId !== state.previewRequestId) return; state.selectedFilePath = normalizeBrowserPath(data.path || normalizedPath); syncFileBrowserSelection(); @@ -4306,6 +4404,8 @@ syncFileBrowserSelection(); renderFileBrowserPreviewEmpty('无法预览', err.message || '当前文件无法打开'); setFileBrowserStatus(err.message || '当前文件无法打开', 'error'); + } finally { + if (state.previewAbortController === controller) state.previewAbortController = null; } } @@ -4348,11 +4448,14 @@
文件预览
-
选择一个文本文件查看内容
+
选择一个文件查看内容
-
选择一个文本文件查看内容
+
选择一个文件查看内容
+ @@ -4368,6 +4471,8 @@ selectedFilePath: '', directoryRequestId: 0, previewRequestId: 0, + previewAbortController: null, + previewImageUrl: '', overlay, panel: overlay.querySelector('.file-browser-panel'), pathEl: overlay.querySelector('[data-browser-path]'), @@ -4377,6 +4482,8 @@ previewMetaEl: overlay.querySelector('[data-browser-preview-meta]'), previewEmptyEl: overlay.querySelector('[data-browser-preview-empty]'), previewCodeEl: overlay.querySelector('[data-browser-preview-content]'), + previewImageEl: overlay.querySelector('[data-browser-preview-image]'), + previewImageNode: overlay.querySelector('[data-browser-preview-image-node]'), upBtn: overlay.querySelector('[data-browser-up]'), refreshBtn: overlay.querySelector('[data-browser-refresh]'), mobileBackBtn: overlay.querySelector('[data-browser-back]'), @@ -4404,7 +4511,7 @@ }); updateFileBrowserPathBar(); - renderFileBrowserPreviewEmpty('文件预览', '选择一个文本文件查看内容'); + renderFileBrowserPreviewEmpty('文件预览', '选择一个文件查看内容'); loadFileBrowserDirectory(''); } diff --git a/public/style.css b/public/style.css index 5453a66..43d3ce3 100644 --- a/public/style.css +++ b/public/style.css @@ -7380,6 +7380,25 @@ textarea.codex-elicitation-input { background: var(--bg-primary); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +.file-browser-preview-image { + flex: 1; + min-height: 0; + display: flex; + align-items: center; + justify-content: center; + overflow: auto; + padding: 14px; + background: var(--bg-primary); +} +.file-browser-preview-image[hidden] { + display: none; +} +.file-browser-preview-image img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} @media (max-width: 768px) { .modal-field-row { @@ -7409,7 +7428,13 @@ textarea.codex-elicitation-input { order: 3; } .file-browser-layout { - display: block; + display: flex; + flex-direction: column; + } + .file-browser-list-pane, + .file-browser-preview-pane { + flex: 1; + min-height: 0; } .file-browser-panel:not(.preview-active) .file-browser-preview-pane { display: none; diff --git a/scripts/regression.js b/scripts/regression.js index a02ad54..af5e263 100644 --- a/scripts/regression.js +++ b/scripts/regression.js @@ -1641,6 +1641,58 @@ function assertFrontendMarkdownLinkContract() { assert(styleSource.includes('.msg-bubble a.local-file-link'), 'Local file links should have a distinct message style'); } +function assertFrontendFileBrowserImageContract() { + const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); + const styles = fs.readFileSync(PUBLIC_STYLE_PATH, 'utf8'); + const browser = source.slice(source.indexOf('const FILE_BROWSER_IMAGE_EXTENSIONS'), source.indexOf('function syncAttachmentActions')); + assert(browser.includes("new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'svg', 'avif'])"), 'File browser should recognize common image extensions'); + assert(browser.includes('entry.imagePreviewableHint ? \'图片\''), 'Directory listing should prioritize the image hint, including SVG'); + assert(browser.includes("imagePreviewable: button.dataset.imagePreviewable === 'true'"), 'Directory click should forward the server image hint'); + assert(browser.includes('isFileBrowserImagePath(normalizedPath)'), 'Local file links should recognize image paths without directory hints'); + assert(browser.includes('/api/fs/image?sessionId='), 'Image previews should use the authenticated image endpoint'); + assert(browser.includes('/api/fs/read?sessionId='), 'Text previews should retain the text endpoint'); + assert(source.includes('async function fetchAuthImage(url, signal)') && source.includes('Authorization: `Bearer ${authToken}`'), 'Image fetch should use Bearer authentication'); + assert(browser.includes("mimeType === 'image/svg+xml'") && browser.includes('reader.readAsDataURL(blob);'), 'SVG previews should use isolated data URLs'); + assert(browser.includes('URL.createObjectURL(blob)') && browser.includes('URL.revokeObjectURL(state.previewImageUrl)'), 'Raster previews should manage object URL lifetime'); + assert(browser.includes('state.previewAbortController?.abort()') && browser.includes('requestId !== state.previewRequestId'), 'Switching previews should abort or reject stale image requests'); + assert(browser.includes('cancelFileBrowserPreview(state)') && browser.includes('clearFileBrowserImagePreview(fileBrowserState)'), 'Closing and switching views should release image resources'); + assert(browser.includes('state.previewImageNode.onerror = () =>'), 'Image decode failures should enter an error state'); + assert(browser.includes('data-browser-preview-image-node') && styles.includes('.file-browser-preview-image[hidden]'), 'Image container should preserve preview layout on desktop and mobile'); + + const lifecycle = source.slice(source.indexOf('function clearFileBrowserImagePreview(state)'), source.indexOf('function updateFileBrowserPathBar()')); + const released = []; + let aborted = 0; + const state = { + previewRequestId: 3, + previewAbortController: { abort() { aborted += 1; } }, + previewImageUrl: 'blob:first', + previewImageEl: { hidden: false }, + previewImageNode: { + src: 'blob:first', + onload() {}, + onerror() {}, + removeAttribute(name) { if (name === 'src') this.src = ''; }, + }, + previewTitleEl: { textContent: '' }, + previewMetaEl: { textContent: '' }, + previewEmptyEl: { textContent: '', hidden: true }, + previewCodeEl: { textContent: 'old text', hidden: false }, + overlay: { parentNode: { removeChild() {} } }, + escapeHandler() {}, + }; + const createLifecycle = new Function('state', 'URL', 'document', `let fileBrowserState = state;\n${lifecycle}\nreturn { cancelFileBrowserPreview, renderFileBrowserPreviewLoading, closeFileBrowser };`); + const actions = createLifecycle(state, { revokeObjectURL(url) { released.push(url); } }, { removeEventListener() {} }); + actions.cancelFileBrowserPreview(state); + assert(aborted === 1 && state.previewRequestId === 4 && state.previewImageUrl === '', 'Switching previews should abort the old request and invalidate its response'); + assert(released[0] === 'blob:first' && state.previewImageNode.src === '' && state.previewImageEl.hidden, 'Switching previews should clear and revoke the old image'); + state.previewImageUrl = 'blob:second'; + actions.renderFileBrowserPreviewLoading('notes.txt'); + assert(released[1] === 'blob:second' && state.previewCodeEl.textContent === '' && state.previewCodeEl.hidden, 'Switching to text should remove the prior image and old text'); + state.previewImageUrl = 'blob:third'; + actions.closeFileBrowser(); + assert(released[2] === 'blob:third' && state.previewRequestId === 5, 'Closing the browser should release its image and invalidate pending responses'); +} + function assertMarkdownRenderingContract() { const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8'); const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8'); @@ -7128,6 +7180,96 @@ async function runFilePreviewEncodingRegression() { }); } +async function runFileBrowserImageRegression() { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-file-browser-image-')); + const configDir = path.join(tempRoot, 'config'); + const sessionsDir = path.join(tempRoot, 'sessions'); + const logsDir = path.join(tempRoot, 'logs'); + const workspace = path.join(tempRoot, 'workspace'); + mkdirp(configDir); mkdirp(sessionsDir); mkdirp(logsDir); mkdirp(workspace); + + const svg = ''; + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+nAf8AAAAASUVORK5CYII=', 'base64'); + const fixtures = [ + ['tiny.PNG', 'image/png', png], + ['portrait.JpEg', 'image/jpeg', Buffer.from([0xff, 0xd8, 0xff, 0xd9])], + ['photo.jpg', 'image/jpeg', Buffer.from([0xff, 0xd8, 0xff, 0xd9])], + ['motion.gif', 'image/gif', Buffer.from('GIF89a', 'ascii')], + ['image.webp', 'image/webp', Buffer.from('RIFF0000WEBP', 'ascii')], + ['image.bmp', 'image/bmp', Buffer.from('BM', 'ascii')], + ['shape.SvG', 'image/svg+xml', Buffer.from(svg, 'utf8')], + ['image.avif', 'image/avif', Buffer.from('ftypavif', 'ascii')], + ['空 格&图?.png', 'image/png', png], + ]; + fixtures.forEach(([filename, , bytes]) => fs.writeFileSync(path.join(workspace, filename), bytes)); + fs.writeFileSync(path.join(workspace, 'notes.txt'), '文本预览仍然可用', 'utf8'); + fs.writeFileSync(path.join(workspace, 'binary.bin'), Buffer.from([0, 0xff, 1])); + fs.symlinkSync('tiny.PNG', path.join(workspace, 'linked-image.data')); + fs.symlinkSync('notes.txt', path.join(workspace, 'linked-text.png')); + const outsideImage = path.join(tempRoot, 'outside.png'); + fs.writeFileSync(outsideImage, fixtures[0][2]); + fs.symlinkSync(outsideImage, path.join(workspace, 'outside-link.png')); + const maxImageBytes = 20 * 1024 * 1024; + fs.writeFileSync(path.join(workspace, 'limit.png'), Buffer.from([0x89])); + fs.truncateSync(path.join(workspace, 'limit.png'), maxImageBytes); + fs.writeFileSync(path.join(workspace, 'oversized.png'), Buffer.from([0x89])); + fs.truncateSync(path.join(workspace, 'oversized.png'), maxImageBytes + 1); + + const sessionId = 'file-browser-image-session'; + fs.writeFileSync(path.join(sessionsDir, `${sessionId}.json`), JSON.stringify({ id: sessionId, cwd: workspace, title: '图片预览回归', messages: [] })); + const port = await getFreePort(); + const password = 'FileBrowserImage!234'; + await withServer({ PORT: String(port), CC_WEB_PASSWORD: password, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, HOME: tempRoot, CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER }, async () => { + const { ws, messages, token } = await connectWs(port, password); + await nextMessage(messages, ws, (msg) => msg.type === 'session_list'); + const imageUrl = (targetPath) => `http://127.0.0.1:${port}/api/fs/image?sessionId=${sessionId}&path=${encodeURIComponent(targetPath)}`; + const authed = { Authorization: `Bearer ${token}` }; + const list = await fetchAuthedJson(port, token, `/api/fs/list?sessionId=${sessionId}`); + for (const [filename, mimeType, bytes] of fixtures) { + const entry = list.entries.find((item) => item.name === filename); + assert(entry?.imagePreviewableHint === true, `${filename} should be listed as a previewable image`); + const response = await fetch(imageUrl(filename), { headers: authed }); + assert(response.status === 200, `${filename} should be previewable, got ${response.status}`); + assert(response.headers.get('content-type') === mimeType, `${filename} should use ${mimeType}`); + assert(response.headers.get('x-content-type-options') === 'nosniff', `${filename} should disable MIME sniffing`); + assert(Buffer.from(await response.arrayBuffer()).equals(bytes), `${filename} should return original image bytes`); + } + assert(list.entries.find((item) => item.name === 'shape.SvG')?.previewableHint, 'SVG may also have a text hint; image must take precedence'); + assert(list.entries.find((item) => item.name === 'linked-image.data')?.imagePreviewableHint, 'Image symlink should use the target format'); + assert(list.entries.find((item) => item.name === 'linked-text.png')?.imagePreviewableHint === false, 'Text symlink should not be presented as an image'); + assert(!list.entries.some((item) => item.name === 'outside-link.png'), 'Outside symlink should be hidden in directory listing'); + const linkedResponse = await fetch(imageUrl('linked-image.data'), { headers: authed }); + assert(linkedResponse.status === 200 && linkedResponse.headers.get('content-type') === 'image/png', 'Image symlink should use the target MIME type'); + await linkedResponse.body.cancel(); + + const exactLimit = await fetch(imageUrl('limit.png'), { headers: authed }); + assert(exactLimit.status === 200, 'Exactly 20 MiB should remain previewable'); + await exactLimit.body.cancel(); + const rejectionCases = [ + ['oversized.png', 413], + ['notes.txt', 415], + ['linked-text.png', 415], + ['binary.bin', 415], + ['.', 400], + ['../outside.png', 403], + ['outside-link.png', 403], + ]; + for (const [targetPath, status] of rejectionCases) { + const response = await fetch(imageUrl(targetPath), { headers: authed }); + const payload = await response.json(); + assert(response.status === status && payload.ok === false, `${targetPath} should reject with ${status}, got ${response.status}`); + if (targetPath === 'oversized.png') assert(/20 MiB/.test(payload.message), 'Oversized image should report the 20 MiB limit'); + } + const anonymous = await fetch(imageUrl('tiny.PNG')); + assert(anonymous.status === 401, 'Unauthenticated image request should return 401'); + const text = await fetchAuthedJson(port, token, `/api/fs/read?sessionId=${sessionId}&path=notes.txt`); + assert(text.content === '文本预览仍然可用', 'Text preview should remain usable'); + const binary = await fetch(`http://127.0.0.1:${port}/api/fs/read?sessionId=${sessionId}&path=binary.bin`, { headers: authed }); + assert(binary.status === 415, 'Existing binary text preview rejection should remain intact'); + ws.close(); + }); +} + function assertWindowsStartupContract() { const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n'); @@ -7363,6 +7505,12 @@ async function main() { console.log('File preview encoding regression checks passed.'); return; } + if (regressionTarget === 'file-browser-image') { + assertFrontendFileBrowserImageContract(); + await runFileBrowserImageRegression(); + console.log('File browser image regression checks passed.'); + return; + } if (regressionTarget === 'usage-statistics') { assertUsageStatisticsUnitChecks(); assertUsageStatisticsContract(); @@ -7419,6 +7567,7 @@ async function main() { assertComposerSlashRoutingContract(); assertFrontendCcwebPromptContract(); assertFrontendMarkdownLinkContract(); + assertFrontendFileBrowserImageContract(); assertMarkdownRenderingContract(); assertMockCodexAppPromptUserNotTextTriggered(); assertFrontendMcpReloadContract(); @@ -7451,6 +7600,7 @@ async function main() { assertCcwebDisplayImageContract(); assertCcwebListConversationsScopeContract(); await runSessionPreviewMetadataRegression(); + await runFileBrowserImageRegression(); const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-')); const configDir = path.join(tempRoot, 'config'); diff --git a/server.js b/server.js index 3bae6fb..bbb6265 100644 --- a/server.js +++ b/server.js @@ -151,6 +151,18 @@ const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024; const MAX_INSTANCE_ICON_SIZE = 4 * 1024 * 1024; const FILE_BROWSER_MAX_LIST_ENTRIES = 400; const FILE_BROWSER_MAX_PREVIEW_BYTES = 200 * 1024; +const FILE_BROWSER_MAX_IMAGE_BYTES = 20 * 1024 * 1024; +// 图片预览白名单:仅这些扩展名允许通过 /api/fs/image 原样输出。 +const IMAGE_PREVIEW_MIME_TYPES = new Map([ + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.gif', 'image/gif'], + ['.webp', 'image/webp'], + ['.bmp', 'image/bmp'], + ['.svg', 'image/svg+xml'], + ['.avif', 'image/avif'], +]); // MCP 工具可能来自多个 server;20 条会在空查询时静默截断大部分工具。 const COMPOSER_SUGGESTION_LIMIT = 200; const COMPOSER_FILE_CONTEXT_MAX_BYTES = 60 * 1024; @@ -2774,6 +2786,10 @@ function isPreviewTextExtension(filePath) { return TEXT_PREVIEW_EXTENSIONS.has(path.extname(filePath).toLowerCase()); } +function getImagePreviewMimeType(filePath) { + return IMAGE_PREVIEW_MIME_TYPES.get(path.extname(filePath).toLowerCase()) || ''; +} + function isProbablyTextBuffer(buffer) { if (!buffer || buffer.length === 0) return true; const sample = buffer.subarray(0, Math.min(buffer.length, 8192)); @@ -4283,6 +4299,7 @@ function handleFileSystemListApi(req, res, url) { size: kind === 'file' ? finalStat.size : 0, updatedAt: finalStat.mtime.toISOString(), previewableHint: kind === 'file' && (isPreviewTextExtension(entry.name) || !path.extname(entry.name)), + imagePreviewableHint: kind === 'file' && !!getImagePreviewMimeType(resolvedEntryPath), symlink, }); } catch {} @@ -4370,6 +4387,81 @@ function handleFileSystemReadApi(req, res, url) { }); } +function handleFileSystemImageApi(req, res, url) { + const token = extractBearerToken(req); + if (!token || !activeTokens.has(token)) { + return jsonResponse(res, 401, { ok: false, message: 'Not authenticated' }); + } + + const sessionId = sanitizeId(url.searchParams.get('sessionId') || ''); + if (!sessionId) { + return jsonResponse(res, 400, { ok: false, message: '缺少 sessionId' }); + } + + const browseContext = getSessionBrowseContext(sessionId); + if (!browseContext.ok) { + return jsonResponse(res, browseContext.statusCode, { ok: false, message: browseContext.message }); + } + + const target = resolveBrowseTarget(browseContext.rootDir, url.searchParams.get('path') || ''); + if (!target.ok) { + return jsonResponse(res, target.statusCode, { ok: false, message: target.message }); + } + + let fd; + let stat; + try { + fd = fs.openSync(target.realPath, fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0)); + if (process.platform === 'linux' && !isPathInside(browseContext.rootDir, fs.readlinkSync(`/proc/self/fd/${fd}`))) { + fs.closeSync(fd); + return jsonResponse(res, 403, { ok: false, message: '目标路径超出允许范围' }); + } + stat = fs.fstatSync(fd); + } catch (err) { + if (fd !== undefined) fs.closeSync(fd); + return jsonResponse(res, 500, { ok: false, message: `读取图片失败: ${err.message}` }); + } + if (!stat.isFile()) { + fs.closeSync(fd); + return jsonResponse(res, 400, { ok: false, message: '目标路径不是文件' }); + } + + const mimeType = getImagePreviewMimeType(target.realPath); + if (!mimeType) { + fs.closeSync(fd); + return jsonResponse(res, 415, { ok: false, message: '当前文件格式不支持图片预览' }); + } + if (stat.size > FILE_BROWSER_MAX_IMAGE_BYTES) { + fs.closeSync(fd); + return jsonResponse(res, 413, { + ok: false, + message: `图片过大,预览上限为 ${Math.round(FILE_BROWSER_MAX_IMAGE_BYTES / (1024 * 1024))} MiB`, + }); + } + + const headers = { + 'Content-Type': mimeType, + 'Content-Length': stat.size, + 'Content-Disposition': 'attachment', + 'Cache-Control': 'private, no-store, max-age=0', + 'X-Content-Type-Options': 'nosniff', + }; + if (stat.size === 0) { + fs.closeSync(fd); + res.writeHead(200, headers); + return res.end(); + } + + const stream = fs.createReadStream(target.realPath, { fd, autoClose: true, end: stat.size - 1 }); + stream.once('error', (err) => { + if (!res.headersSent) jsonResponse(res, 500, { ok: false, message: `读取图片失败: ${err.message}` }); + else res.destroy(err); + }); + res.once('close', () => stream.destroy()); + res.writeHead(200, headers); + stream.pipe(res); +} + function handleDirectoryPickerListApi(req, res, url) { const token = extractBearerToken(req); if (!token || !activeTokens.has(token)) { @@ -9716,6 +9808,10 @@ const server = http.createServer((req, res) => { return handleFileSystemReadApi(req, res, url); } + if (req.method === 'GET' && url.pathname === '/api/fs/image') { + return handleFileSystemImageApi(req, res, url); + } + if (req.method === 'GET' && url.pathname === '/api/fs/directories') { return handleDirectoryPickerListApi(req, res, url); } diff --git a/恢复原生计划工具 TO DO list.csv b/恢复原生计划工具 TO DO list.csv new file mode 100644 index 0000000..f230364 --- /dev/null +++ b/恢复原生计划工具 TO DO list.csv @@ -0,0 +1,7 @@ +id,item,status,done_at,notes +1,查证官方版本与工具注册条件,DONE,2026-09-30,官方0.157.1缺省关闭 +2,核对ccweb实际进程和会话配置,DONE,2026-09-30,未配置tools.update_plan.enabled +3,显式启用原生计划工具,IN_PROGRESS,,覆盖App Server线程和CLI +4,验证配置开关和现有协议回归,TODO,,现有回归断言 +5,重新打包并提交推送,TODO,,沿用已授权的发布流程 +6,检查运行会话并重启服务,TODO,,无其他running会话才重启