feat: ship skill and MCP workflows with signed updater for v0.0.7

This commit is contained in:
xiaohan.yuan committed 2026-10-21 00:04:17 +08:00
1 parent 1b52df0603
commit a01085c656
136 files changed
+18646 -217

No files matched your search

+56
View File
@@ -0,0 +1,56 @@
!include "nsProcess.nsh"
!macro customCheckAppRunning
${GetParameters} $R0
ClearErrors
${GetOptions} $R0 "/YCODEWAITPID=" $R1
${IfNot} ${Errors}
${If} $R1 <= 0
SetErrorLevel 2
Quit
${EndIf}
; Updates wait for this instance only; never kill another installation.
; ?e captures OpenProcess's native error in the same System call.
System::Call 'kernel32::OpenProcess(i 0x100000, i 0, i R1) p .R2 ?e'
Pop $R3
${If} $R2 != 0
System::Call 'kernel32::WaitForSingleObject(p R2, i 120000) i .R4'
System::Call 'kernel32::CloseHandle(p R2)'
${If} $R4 != 0
SetErrorLevel 2
Quit
${EndIf}
${ElseIf} $R3 != 87
; ERROR_INVALID_PARAMETER means the watched PID already exited.
SetErrorLevel 2
Quit
${EndIf}
; Renderer children can outlive the PID. Check both the normal wait path and
; the ERROR_INVALID_PARAMETER path for this exact installed executable.
System::Call 'kernel32::SetEnvironmentVariable(t "YCODE_UPDATE_TARGET_DIR", t "$INSTDIR\${APP_EXECUTABLE_FILENAME}") i .R4'
${If} $R4 == 0
SetErrorLevel 2
Quit
${EndIf}
nsExec::ExecToStack `"$PowerShellPath" -NoLogo -NoProfile -NonInteractive -Command "trap { exit 2 }; $$ErrorActionPreference='Stop'; $$target=([IO.Path]::GetFullPath($$env:YCODE_UPDATE_TARGET_DIR)).TrimEnd([char]92); $$deadline=[DateTime]::UtcNow.AddSeconds(5); do { $$all=@(Get-CimInstance Win32_Process | Where-Object { $$_.Name -ieq '${APP_EXECUTABLE_FILENAME}' }); $$found=$$false; foreach ($$process in $$all) { if (-not $$process.ExecutablePath) { $$found=$$true; continue }; if ((([IO.Path]::GetFullPath($$process.ExecutablePath)).TrimEnd([char]92)) -ieq $$target) { $$found=$$true; break } }; if (-not $$found) { exit 0 }; Start-Sleep -Milliseconds 500 } while ([DateTime]::UtcNow -lt $$deadline); exit 1"`
Pop $R4
Pop $R5
System::Call 'kernel32::SetEnvironmentVariable(t "YCODE_UPDATE_TARGET_DIR", p 0) i .R5'
StrCmp $R4 "0" +3
SetErrorLevel 2
Quit
${Else}
; For manual setup/uninstall, refuse to proceed while YCode is open.
!insertmacro nsProcess::FindProcess "${APP_EXECUTABLE_FILENAME}" $R0
${If} $R0 == 0
MessageBox MB_OK|MB_ICONEXCLAMATION "Please close ${PRODUCT_NAME} before continuing." /SD IDOK
SetErrorLevel 2
Quit
${EndIf}
${If} $R0 != 603
SetErrorLevel 2
Quit
${EndIf}
${EndIf}
!macroend
@@ -0,0 +1,122 @@
# Claude 追加与压缩协议本地验证
日期:2026-10-20
验证状态:SDK/CLI 协议探针和生产 ClaudeKernel 追加、压缩集成回归通过;Electron 主进程端到端结果另见本轮交接记录,不代表 WorkBuddy 1:1 验收。
## 1. 验证对象
- `@anthropic-ai/claude-agent-sdk`:`0.3.304`
- SDK 内置 Claude Code:`2.1.304`
- `node_modules/@anthropic-ai/claude-agent-sdk-win32-x64/claude.exe`
- Claude Code SHA-256:`019E5174D5D954585EDB1A946FE280E44A973050B4897034ADF0E16306F85FDD`
- 运行平台:Windows x64
本验证使用本机回环模拟的 Anthropic Messages SSE,不连接真实模型服务。HOME、USERPROFILE、
`CLAUDE_CONFIG_DIR` 和 `CLAUDE_SECURESTORAGE_CONFIG_DIR` 均重定向到临时目录,仅使用 dummy key。
因此本文件证明的是当前 SDK/CLI 的消息协议和帧归属行为,不是模型质量、真实供应商行为或
WorkBuddy 原版行为。WorkBuddy 原版结论仍必须通过 REA 采集并记录其 Evidence ID。
## 2. 追加协议探针
探针文件:
- `scripts/claude-append-protocol-probe.ts`
- `scripts/build-claude-append-probe.mjs`
探针通过 SDK 原始 `query()` API 创建一个保持打开的异步输入流,流程如下:
1. 发送带 UUID `INITIAL` 的 `PROBE_INITIAL`。
2. 挂起首个模型请求的 SSE 响应。
3. 在首个请求仍在途时,向同一输入流追加带 UUID 的 `PROBE_APPEND_A`、`PROBE_APPEND_B`。
4. 不调用 `interrupt()`,不关闭输入流;释放首个响应后等待全部逻辑回合结束。
5. 对照捕获的模型请求体、SDK 运行时帧和 `result` 帧。
本次生成报告:`.tmp/claude-append-protocol-probe.json`,顶层结果为 `PASS`。
### 请求侧结果
请求 1 只包含 `PROBE_INITIAL`,且正常完成,未被追加消息取消或改写。
请求 2 包含:
1. 初始用户消息 `PROBE_INITIAL`;
2. 首轮 assistant 文本 `answer-1:PROBE_INITIAL`;
3. 同一个 user message 中按顺序合并的 A、B 文本块。
本次 CLI 将 A 序列化为 `PROBE_APPEND_A\n`,B 保持 `PROBE_APPEND_B`。因此追加消息不会注入
已经在途的 HTTP 请求,而是在首轮完成后成为下一逻辑回合的输入;相邻追加可合并为一个 user message。
### 结果与归属
本次观察到两个 `result`:
- `result[0]` 只归属 `INITIAL` UUID;
- `result[1]` 同时归属 A、B 两个 UUID,其中 `user_message_uuid` 是最后一条 B 的 UUID。
两个 `assistant` 帧也按相同边界归属:首轮 assistant 不包含 A/B UUID,合并后的第二轮 assistant
同时包含 A/B UUID。输入文本没有产生 SDK `user` echo 帧。
当前版本的 assistant/result 帧未观察到 `prompt_id`。可用的稳定归属键是
`user_message_uuid` / `user_message_uuids`;在合并回合中,`user_message_uuid` 只是
`user_message_uuids` 的最后一项,不能代表该 result 只覆盖 B。
关键结论:
- 一个 `result` 可能覆盖多个追加 UUID;不能用“收到一次 result”直接结算所有排队发送。
- 在途请求和后续追加属于不同逻辑回合,当前观察到一个逻辑回合对应一个 `assistant` 和一个 `result`。
- 该协议结论来自 SDK 原始异步输入流;生产 `ClaudeKernel` 的实现和验证单独记录在第 4 节。
## 3. 原生压缩边界复验
使用 `scripts/claude-kernel-integration.ts` 的 `compact` 分支,设置
`CLAUDE_PROTOCOL_PROBE=1` 后运行真实 SDK + 内置 CLI + 回环假模型。观察到
`system/compact_boundary`,后续收到成功 `result`,探针断言通过。
集成回归同时继续发送压缩后的请求,断言旧 ASK 内容不再出现在上游请求中。生产命令专用路径不附加普通聊天提醒,成功必须同时收到原生边界和成功结果;应用可见历史不随压缩删除。没有证明:
- 真实模型压缩后的内容质量或语义完整性;
- 自动压缩的 token 阈值和触发条件;
- 并发追加用户消息与压缩同时发生时的行为;
- 压缩失败、重试和真实供应商错误分类。
## 4. 对 YCode 内核的影响
生产 `src/main/agent/claude/claude-kernel.ts` 保留单个活动根运行,新增 `appendInput()`,不放开并发 `runTurn()`:
1. 输入带独立 UUID 推入同一原生输入流,不取消当前请求,不声称改写已发出的上游请求。
2. 按 `user_message_uuid(s)` 路由后续输入组;首个 result 不会提前结算仍待交付的追加项。
3. 前一组完成后先保存旧 assistant;匹配到新组的原生身份帧后,由宿主原子保存完整用户消息及新 assistant,再确认对应队列项交付。A/B 可合并到同一原生组,但正式历史保留两条用户消息。
4. 后续组沿用根运行的冻结模型、权限和工作区;新组保存期间工具/审批等待交接,避免挂到旧 assistant。
5. 取消会关闭并排空原生生产者,再允许下一运行;推入前失败可明确重试,推入后未确认的消息标记 unknown,不自动重放。
`scripts/claude-kernel-integration.ts` 直接调用生产内核验证双追加、不打断首请求、身份归属、交接回调、空闲追加拒绝、原生压缩和压缩后请求。2026-10-20 重新构建后运行 `CLAUDE_PROTOCOL_PROBE=1`,输出 PASS(16 requests / 43 events / 9 turns)。这些数字属于确定性回环夹具,不是实际模型效果或完整 UI 验收。
## 5. 复验命令
在仓库根目录执行:
```powershell
npx tsc --ignoreConfig --noEmit --target ES2023 --module ESNext --moduleResolution Bundler --strict --skipLibCheck --types node --lib ES2023 scripts/claude-append-protocol-probe.ts
node scripts/build-claude-append-probe.mjs
node .tmp/claude-append-protocol-probe.mjs
node scripts/build-claude-kernel-regression.mjs
$env:CLAUDE_PROTOCOL_PROBE='1'
node .tmp/claude-kernel-integration.mjs
npm run typecheck
```
预期:
- 追加探针输出 `result: PASS`,报告写入 `.tmp/claude-append-protocol-probe.json`;
- 压缩探针输出 `probe: compact`、`boundary: true`,随后 integration 输出 `result: PASS`;
- `npm run typecheck` 通过。
`node .tmp/claude-append-protocol-probe.mjs` 使用随机回环端口和临时配置目录;报告中的端口、
UUID 和消息 UUID 每次运行都会变化。稳定复验依据是版本、二进制哈希、帧结构和上述断言。
## 6. 限制与后续
- 探针只覆盖 Windows x64 和当前 SDK/CLI 版本,不能外推到其他平台或版本。
- 假模型只返回固定文本,不覆盖真实长上下文、网络重试、限流和供应商差异。
- 没有验证 WorkBuddy 原版是否采用相同的追加/压缩交互;该比较需另行通过 REA 完成并记录证据。
- 原生追加与压缩混发被产品入口拒绝;真实网络故障、工具密集追加、多平台进程崩溃注入仍需要进一步验证。
@@ -0,0 +1,42 @@
# MCP、技能与统一输入区接入
日期:2026-10-20。执行来源:会话 `01a158f5-eea4-7e43-893c-21217cb00ed5` 最后确认计划。
## Dev 反馈补漏:管理页面
用户在 Dev 中发现技能/连接器页面仍显示维护:上一轮只接通了后端及输入区,没有恢复管理页,这是实施遗漏。现从本仓库已有版本恢复 SkillsPage / ConnectorsPage 的组件、图标和样式,不重新采集或绕过 REA;技能页补传 cwd、使用/创建/查找回调,ChatPane 恢复对应事件接收及返回能力管理页后的目录刷新。Dev 的 Vite HMR 日志已确认页面更新;专家页面不在本次开放范围内。
技能隔离 Electron 回归 `node scripts/test-skills-ui.mjs` 37 项通过,使用真实生产 skills 模块、隔离文件存储及受控文件选择器,覆盖导入/覆盖冲突、启停、详情、导出、卸载、回调与无效包。报告 `.tmp/skills-ui-3FPN3y/report.json`;已检查已安装技能截图。远程技能市场仍明确未连接,不伪造目录数据。
连接器页面隔离 Electron 回归 `node scripts/test-connectors-page.mjs` 26 项通过(最终报告 `.tmp/connectors-page-5zSU8J/report.json`):真实页面/样式,受控 MCP 宿主,覆盖列表、详情、管理弹窗、批准/启用、工具开关、重连、配置编辑入口、搜索、删除取消及弹窗内错误显示;不是外部 MCP 服务连接验收。主线程已检查卡片与弹窗错误截图;隐藏窗口截图已增加预绘制以避免捕获旧帧。类型检查、会话组件回归和最新完整构建通过;构建日志 `.tmp/capability-management-build.log`。
## 实现
- 继续使用 Claude Agent SDK / Claude Code,不切换内核。YCode 自有配置目录、模型、凭据和会话管理保留;停止发现项目 `.claude/skills`,保留受启停和校验约束的 `.agents/skills`。这不是 OS 文件系统隔离。
- 技能管理和 MCP 管理 IPC 恢复真实实现。MCP 宿主维护唯一连接,Claude 通过四个小型桥接工具按需发现、调用 MCP、加载技能正文及相对资源;执行前重新校验启用、批准哈希、工具开关和连接身份。技能脚本仍需原生执行工具及权限流程。
- 加号与斜杠共用能力目录、搜索和选择状态,消息级技能/MCP/压缩标签贯通草稿、排队、正式历史和重发。选择标签不代表能力已经运行。
- 统一输入区决定交互、错误提示、队列与普通输入的优先级;接管时隐藏而不删除队列或草稿。权限位置为加号之后;Esc 不停止生成,明确停止按钮保留。
- 生成中有效输入进入会话持久队列(最多 19 条);排序、删除、编辑取回、恢复与状态推送已接通。编辑取回保留磁盘暂存记录,直到实际提交确认,不先删除唯一副本。崩溃恢复或交付不明会暂停,不自动重试 unknown。
- 立即发送走同一原生输入流,不取消重发。按 UUID 识别后续组,宿主提交历史后才从队列移除;允许 A/B 合并原生组,历史保留两条输入。首个结果不结算后续消息。当前运行配置冻结,普通队列实际派发时读取后续设置。
- 模型/思考设置串行保存,生成期间只允许改下一请求设置,不能改工作区、模式或权限;首次建会话期间的选择也保留。当前消息的模型和上下文窗口不跟随下次选择改变。
- 原生 `/compact` 专用路径必须得到压缩边界和成功结果,不用总结请求代替;禁止忙碌、队列未处理、无历史或混合附件/能力/正文时执行。保留可见历史。
- 上下文采用真实流式用量及已有内核 summary 分类估计,不按输出字符计算,不把跨多请求的 result 聚合用量当作当前上下文。查询单会话单在途,结果绑定运行、模型和输入组。
## 已验证
- `npm run typecheck` 和完整 `npm run build`(最终日志 `.tmp/composer-final-build.log`)。
- `npm run test:composer-capabilities`:队列常规/并发/故障恢复、输入区组合、能力选择器 30 项、请求设置 5 组、上下文、能力桥接 36 项(含根运行取消传播);对抗队列 16 组。
- `npm run test:chat`、`npm run test:conversation`、`npm run test:sessions`、`npm run test:tool-approvals`、`npm run test:workbench-escape`、`npm run test:references`、`npm run test:persistence`。
- 真实 SDK/CLI 回环协议和生产内核集成:无打断双追加、身份合并/归属、交接回调、取消后续聊、空闲追加拒绝、真实原生压缩边界和压缩后请求。`CLAUDE_PROTOCOL_PROBE=1` 输出 PASS(16 requests / 43 events / 9 turns)。环境和探针细节见 [Claude 追加与压缩协议验证](Claude追加与压缩协议验证-2026-10-20.md)。
- `npm run test:claude-append:app`:加载真实生产 main/preload/renderer,隔离用户数据及配置,回环 SSE 挂起首响应,通过界面发送两条队列消息并连续点击立即发送,确认两项保持 delivering 直到原生身份交付;首请求未中断、A/B 合并后续原生组、两条稳定身份写入历史、正确分段 assistant、最后队列清空。26 项通过,0 renderer 错误,报告 `.tmp/claude-append-app-DFYbsq/report.json`;主线程已查看 `append-queue.png` 与 `append-final.png`,该次为浅色桌面截图。
- 最终扩展回归:`.tmp/claude-append-app-s0u84t/report.json`,39 项通过、0 renderer 错误。在首个请求仍挂起时通过真实 IPC 选择 B 再 C,请求端点顺序为 A / A / C,助手元数据也分别为 A / A / C;下一独立请求完成后才截最终图。主线程已查看深色、720px 窄窗截图;窄窗发现长模型标签遮住权限入口,修为权限图标紧跟加号、模型名称截断,并加入权限按钮可见且中心命中不被遮挡的 DOM 检查。该响应式修正是 YCode 可用性处理,未宣称是原版同尺寸实测规则。
## 证据与未验证边界
- 原版静态依据:WorkBuddy 5.8.2 JS Evidence `ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb`;CSS Evidence `ev_377c8790468b3c67dad8577aabb327f98d3140659f0fffdbabfcff5a40cc9e94`。队列最大高度 224px、顶部圆角及向下重叠沿用静态依据,不新增数量标题或折叠卡片。
- REA 对 `http://127.0.0.1:9223` 返回 endpoint_unreachable / provider_unavailable,未绕过 REA 获取原版页面。实际激活调用链、图标精确差异、浮层尺寸和动态截图仍待原版可达后补证。
- 任务组合策略已有独立测试,但当前 Claude 内核没有完整团队成员/后台任务事件来源,未伪造数据接线;这部分不能宣称完整任务功能或 1:1。
- 自动能力桥接通过宿主注入测试;真实外部 MCP 服务故障/真实供应商长上下文/真实压缩质量/多平台进程崩溃与独立安全审查未完成。
- 全部回环测试使用隔离配置及 dummy key,不证明真实模型效果。UI fixture 不等于真实后端,真实 Electron 后端回环也不等于供应商线上验收。
- 能力选择器的键盘/IME 等通过组件回归,尚未完成全矩阵真实 Electron 键盘交互;图片/工具密集引导、实际上游断连与取消不在本次 Electron 文本回环范围内。原生 UUID 帧直接断言在内核协议测试中,Electron 测试通过请求合并、持久身份、分段历史与队列移除交叉确认。
- 本轮不提交 Git、不发布、不安装、不重启用户安装版;保留工作区已有升级模块等修改。
+283
View File
@@ -0,0 +1,283 @@
# YCode 更新发布说明
> 2026-10-20 23:53(Asia/Shanghai)追加发布:线上 latest 已更新为 **0.0.7**。包含技能/MCP 输入引用、Archify 内置推荐与官方 GitHub API 固定包安装、仓库 owner 本地头像、统一详情与全局遮罩、原位安装及新任务“去使用”、斜杠原子消费和焦点恢复修正。以下旧版本条目保留为历史,不代表当前 latest。
>
> 安装包:`release/publish/0.0.7/YCode-0.0.7-Setup-x64.exe`,**237351255 字节**,SHA-256 **506768934c92979ba38c4ab922b58d2de98b590a125fd1a5dacd8dadd2ddaff3**。公开地址:`http://192.168.10.6/ycode/releases/versions/0.0.7/YCode-0.0.7-Setup-x64.exe`。
>
> 完成升级回归、发布流程17项自测、类型检查与完整构建。使用官方 checksums.json 校验过的 Electron 37.10.3 缓存 ZIP,通过命令行 electronDist 打包,未更改全局下载配置;包内版本、固定 GitHub 来源与 commit、新详情/遮罩/安装状态/Slate 删除接口、离线头像哈希和公钥一致性检查通过。先生成 Ed25519 签名清单,再以 `npm run release -- --version 0.0.7 --resume` 执行部署预检、SSH 发布与 HTTP 清单复核,历史目录不覆盖,暂存目录已清理。
>
> 正式 `0.0.7` 生产 EXE 使用隔离数据启动,真实 IPC 检查服务器返回 up-to-date;模拟旧版本通过正式更新后端实际完整下载,大小/SHA-256 一致、状态 ready,安装器适配器调用为0,真实安装文件/注册表状态未变化。报告:`.tmp/updates-live/2026-10-20T15-53-57-094Z/report.json`。已检查生产包启动截图。此验证没有执行 NSIS 安装/覆盖升级。
>
> 安装包 Authenticode 状态为 **NotSigned**,不能将 Ed25519 清单签名表述为 Windows 代码签名。日志:`.tmp/release-0.0.7-updates.log`、`.tmp/release-0.0.7-build.log`、`.tmp/release-0.0.7-package.log`、`.tmp/release-0.0.7-package-verification.json`、`.tmp/release-0.0.7-prepare.log`、`.tmp/release-0.0.7-publish.log`、`.tmp/release-0.0.7-live-verification.log`。本地版本与 lock 更新为0.0.7,未 Git 提交或打 tag,未安装或重启用户安装版。
> 2026-10-20 追加发布:线上 latest 已更新为 `0.0.6`,移除升级前的原生二次确认窗。
> 安装包为 `release/publish/0.0.6/YCode-0.0.6-Setup-x64.exe`,大小 `237189173` 字节,
> SHA-256 `7e672b4a76349fb799e6c509c2901a199e5e6bb4266dc4e78d6bffe978c33c5c`。
> 更新测试、完整构建、打包内版本/公钥/旧弹窗移除/ASAR修复检查、SSH 发布、HTTP 签名清单和完整安装包哈希复核通过。
> 首次一键执行在打包时访问 GitHub 超时;使用本机已有 Electron 37.10.3 缓存 ZIP(按 electron 包自带 checksums.json 验证 SHA-256)完成打包,
> 生成签名清单后通过 `npm run release -- --version 0.0.6 --resume` 完成发布。未修改发布脚本或全局下载配置。
> 日志:`.tmp/release-0.0.6.log`、`.tmp/release-0.0.6-package-retry.log`、`.tmp/release-0.0.6-resume.log`。
> 未执行安装器、覆盖升级或重启用户应用;下方 `0.0.5` 状态保留为历史记录。
日期:2026-10-20
状态:发布工具、服务端与推荐版本 `0.0.5` 已发布;隔离启动和真实签名下载已验收,
尚未执行真实覆盖升级验收
## 1. 当前结果
- 更新服务器:`local001`,HTTP 基址 `http://192.168.10.6/ycode/releases/`。
- 发布根目录:`/srv/ycode`;不可变版本目录:`/srv/ycode/releases/versions/`。
- SSH 暂存目录:`/srv/ycode-staging`,不对外提供 HTTP 访问。
- nginx 独立片段:`/etc/nginx/snippets/ycode-releases.conf`。
- nginx 首次变更前备份:`/var/backups/nginx/ycode-before-20261020T091317Z-default`。
- 下载页路由变更前备份:`/var/backups/nginx/ycode-before-20261020T092709Z-default`。
- `/srv/baocode` 和原有 `/baocode/` location 未改动。
- `/ycode/releases/latest.json` 当前返回 200、`Cache-Control: no-store`,版本为 `0.0.5`。
- `/ycode/releases/` 当前返回 200,显示从签名清单生成的 `0.0.5` 下载页;页面无脚本。
- 线上 `latest.json` 于 `2026-10-20T10:27:49.041Z` 发布,SHA-256 为
`d985b7b94ed9e4661eec03489e801ba41aff295b4d9ea6be8a6930edf8247245`;下载页
SHA-256 为 `42fb30160c2250ff04f23b3505a6fb0bb97aa3a1a91cb91ef4dd0cf0c314f285`。
- 推荐线上安装包实测 `237106337` 字节,SHA-256 为
`a3b8ece96077af81f9c8c676cb7d0d71f16a5d73332bc48b865c5cba7e92fd0d`。
- `0.0.2`、`0.0.3`、`0.0.4` 安装包作为不可变历史版本保留,均已被 `0.0.5` 取代。
- 发布完成后 `/srv/ycode-staging` 为空;所有访问和文件校验均已完成。
- 公钥:`resources/updates/public-key.pem`;Electron `extraResources` 会把它打包为
`resources/updates/public-key.pem`。
- 公钥 SPKI SHA-256 指纹:`320a1f9016e9fbb7097074a68e1d3d15633fe05da5cb418f448372d37a8c3ea5`。
- 私钥:`%USERPROFILE%\.ycode-release\update-signing.key`,仅当前 Windows 账户可访问,不在仓库内。
当前 `package.json` 为内部版本 `0.0.5`,安装包名为
`YCode-${version}-Setup-${arch}.${ext}`。`0.0.5` 已构建、签名清单验证并发布到
`local001`。本轮未执行安装包 Authenticode 复验,不能把该发布包声明为已做代码签名。
## 2. 清单契约
完整契约见 `resources/updates/manifest-contract.md`。
```json
{
"payload": {
"schemaVersion": 1,
"version": "0.0.5",
"publishedAt": "2026-10-20T10:00:00.000Z",
"notes": { "zh": "更新说明", "en": "Release notes" },
"platforms": {
"windows-x64": {
"url": "http://192.168.10.6/ycode/releases/versions/0.0.5/YCode-0.0.5-Setup-x64.exe",
"size": 123456789,
"sha256": "64-lowercase-hex"
}
}
},
"signature": "base64-ed25519-signature"
}
```
- Ed25519 签名覆盖且仅覆盖 `Buffer.from(JSON.stringify(payload), "utf8")`。
- payload 字段插入顺序属于签名契约,发布脚本固定为 `schemaVersion, version, publishedAt, notes, platforms`。
- SHA-256 为小写十六进制;大小是实际安装包字节数。
- 安装包 URL 必须是绝对 HTTP(S)、无查询、无片段、无凭据,并精确位于
`<base>/versions/<version>/` 下。
- 客户端只接受与清单同源的安装包 URL 和同源重定向。
- `latest.json` 禁止缓存;版本目录和安装包内容不可覆盖。
## 3. 发布命令
### 一键打包发布(推荐)
在 Windows x64 发布机的项目目录运行:
```powershell
npm run release
```
按提示输入新的正式版本号,例如 `0.0.6`,随后自动执行预检、更新本地版本、升级回归测试、
完整构建、NSIS 打包、签名清单生成、部署预检、SSH 发布与 HTTP 清单复核。默认发布到
`local001`,继续使用现有用户目录中的私钥和仓库公钥,不需要手动复制安装包或清单。
只输入版本号时会使用通用更新说明,也可以直接传参:
```powershell
npm run release -- --version 0.0.6 --notes-zh '移除升级前的二次确认窗。' --notes-en 'Remove the upgrade confirmation dialog.'
npm run release -- --help
```
- 仅接受 `x.y.z` 正式版本;新发布版本必须高于服务器 latest,且不得低于仓库版本。
- 安装包、签名清单保存在 `release/publish/<版本>/`;不会覆盖历史安装包,也不会自动安装或重启应用。
- 同步更新 `package.json`、`package-lock.json` 的项目版本,不执行 Git 提交或打 tag。
- 任一步失败即停止。本地目标版本和已有产物保留,不自动回滚服务器。
- 已完成安装包和签名清单、但发布中断时,可复用原始产物重试;不会重新打包或重新签名:
```powershell
npm run release -- --version 0.0.6 --resume
```
复用模式仍校验版本、签名、大小、哈希和安装包身份;服务器同版本必须与本地清单完全一致,
更高版本或同版本不同内容都会被拒绝。若失败时尚未生成完整签名清单,不能使用复用模式。
若已留下不完整的版本输出目录,普通模式不会覆盖它;可使用更高版本重新发布,或确认该版本
未发布后手动移走不完整产物,再重新运行同版本发布。
脚本使用本地独占锁避免重复运行;异常终止留下锁时,应先确认没有发布进程运行,再处理报错指出的锁文件。
`npm run test:release` 使用隔离的假执行器验证流程,不连接升级服务器、不实际打包或发布。
以下为手动分步命令,便于排障;通常无需执行。
先完成协调方构建,确认安装包已经包含 updater 并完成基础启动验收。不要发布当前
`release/YCode-v0.01-Setup-x64.exe`:它的 ProductVersion 是 `0.0.1`,且没有更新能力。
### 3.1 准备签名清单
```powershell
Set-Location 'C:\Users\Administrator\code\YCode'
node scripts/updates/prepare-release.mjs `
--installer 'release\YCode-0.0.5-Setup-x64.exe' `
--version '0.0.5' `
--notes-zh '修复 Electron ASAR 安装标记误判,恢复重启升级。' `
--notes-en 'Fix Electron ASAR installation-marker misclassification and restore restart-based upgrades.' `
--output '.tmp\updates\0.0.5\latest.json'
```
该命令在生成清单前强制检查:
1. 文件名安全且为 `.exe`。
2. 实际 EXE ProductVersion 等于 `--version`。
3. `package.json` 的版本等于 `--version`。
4. 版本高于无 updater 的 `0.0.1` 基线。
5. 安装包大小为 20 MiB 到 4 GiB,SHA-256 从实际文件计算。
6. 签名能被仓库公钥验证。
### 3.2 只读预检
```powershell
node scripts/updates/deploy.mjs `
--manifest '.tmp\updates\0.0.5\latest.json' `
--installer 'release\YCode-0.0.5-Setup-x64.exe' `
--transport ssh `
--ssh-host local001 `
--dry-run
```
预检会重新计算安装包哈希、验证清单签名、读取并验证服务器现有
`latest.json`,然后检查版本单调性;不会修改服务器。
### 3.3 正式发布
```powershell
node scripts/updates/deploy.mjs `
--manifest '.tmp\updates\0.0.5\latest.json' `
--installer 'release\YCode-0.0.5-Setup-x64.exe' `
--transport ssh `
--ssh-host local001
```
发布顺序:
1. 安装包和清单先上传到 `/srv/ycode-staging/<随机 ID>/`。
2. 服务端重新校验大小和 SHA-256。
3. 新版本先以目录重命名方式进入不可变 `versions/<version>/`。
4. 从已验签 payload 生成转义后的静态 `index.html`,包含版本、安装包链接、SHA-256 和中英文说明。
5. 在文件锁下再次确认现有 `latest.json` 未在发布期间变化。
6. 先原子替换 `index.html`,再原子替换 `latest.json`;最后一步失败时回滚旧页面。
任何一步失败,旧 `latest.json` 保持不变。已落盘但未成为 latest 的版本目录可以作为
同版本重试的幂等产物;若同版本目录中的包哈希不同,发布会被拒绝。
### 3.4 发布后复核
```powershell
node scripts/updates/deploy.mjs `
--manifest '.tmp\updates\0.0.5\latest.json' `
--installer 'release\YCode-0.0.5-Setup-x64.exe' `
--transport ssh `
--ssh-host local001 `
--dry-run
Invoke-WebRequest 'http://192.168.10.6/ycode/releases/latest.json' -UseBasicParsing |
Select-Object StatusCode,Headers,Content
```
## 4. 验证命令
### 4.1 发布工具自测
```powershell
node scripts/updates/selftest.mjs
node scripts/updates/contract-selftest.mjs
```
`selftest.mjs` 使用临时目录、临时密钥和本地 HTTP 服务,不上传 SSH,也不会写正式站点。
当前覆盖:
- 密钥生成、公钥格式和 Windows ACL。
- SemVer 优先级和 build metadata 规则。
- 签名精确载荷、说明/哈希/签名篡改拒绝。
- URL 查询、片段、凭据、跨版本路径、时间格式和小写哈希策略。
- 本地假传输发布、HTTP 拉取、大小与 SHA-256 复核。
- 幂等重发、同版本冲突、降级拒绝、失败前旧 latest 保持。
- 下载页文本转义、无脚本、latest/页面同步切换和失败时页面回滚。
`contract-selftest.mjs` 用 esbuild 加载 `src/main/updates/manifest.ts`,验证发布端清单能被
后端实际解析、验签和路径策略接受。
### 4.2 服务端验证
```powershell
ssh local001 "nginx -t"
Invoke-WebRequest 'http://192.168.10.6/ycode/releases/latest.json' -Method Head -UseBasicParsing
Invoke-WebRequest 'http://192.168.10.6/ycode/releases/' -UseBasicParsing
Invoke-WebRequest 'http://192.168.10.6/baocode/' -Method Head -UseBasicParsing
```
当前预期状态:YCode latest 为 200 且版本为 `0.0.5`,下载页为 200 并包含线上版本、链接和哈希,
`/baocode/` 为 403;YCode 不能开启目录浏览。
`0.0.5` 的隔离启动和真实 LAN 清单/安装包下载验证已通过:
- 脚本:`node scripts/verify-updates-live.cjs --expected-version 0.0.5`
- 结果:PASS;实际启动、签名清单校验、完整下载、大小和 SHA-256 均通过。
- 报告:`.tmp/updates-live/2026-10-20T10-28-49-489Z/report.json`。
- 产物验证:`app.asar` 内版本为 `0.0.5`,更新实现包含修复后的 ASAR 安装标记判定,打包公钥与仓库一致。
- 边界:本轮没有启动 NSIS 安装器,没有重启或修改当前用户安装,因此这不等于真实覆盖升级验收。
## 5. 首次更新基线
现有 `0.0.1` 客户端没有 updater,不能通过更新服务器升级到 `0.0.5`。
正在运行的 `0.0.3`、`0.0.4` 客户端受旧版检查器的 ASAR 安装标记误判影响,应手工安装
`0.0.5` 引导包,不能把“发现更新”视为该路径已经可用。`0.0.1` 同样需要手工安装
`0.0.5`。其他旧版客户端的真实覆盖路径仍以隔离环境复测为准。引导到 `0.0.5` 后:
```text
0.0.1(无 updater,手动安装 0.0.5)
0.0.3 / 0.0.4(检查器存在已知问题,手动安装 0.0.5)
→ 启动 0.0.5 并确认实际版本
→ 后续版本由 0.0.5 检查、下载、签名校验
→ 用户在应用中确认重启
→ NSIS 覆盖安装
→ 新版本启动后核对实际版本
```
不能把“上传成功”“清单可下载”或“客户端显示发现更新”当作端到端升级验收。至少还需要一次
隔离安装目录的真实 `0.0.4 → 0.0.5` 覆盖安装测试。
## 6. 密钥管理
- 私钥只允许存在于发布机用户目录或离线加密备份,不进入仓库、服务器、构建产物、聊天记录和日志。
- 公钥随客户端分发并作为固定信任锚。
- 当前私钥 ACL 仅授予 `WIN-JA8HHLU9216\Administrator`。
- 发布前可运行 `init-key.mjs` 的默认路径检查;路径已存在时脚本会拒绝覆盖。
- 私钥丢失或泄露时,不能用旧客户端认可的常规更新完成轮换。需要构建一个包含新公钥的
临时安装包,手工分发并安装,同时提升版本;之后再由新密钥继续发布。
- 不要为了“省事”在服务器上保留私钥,也不要将私钥复制到 `/srv/ycode`。
## 7. 已知限制
- 当前为 LAN HTTP,仅适用于能访问 `192.168.10.6` 的设备。没有 TLS、登录鉴权、限流和外部 CDN。
- 签名可防止安装包和清单被替换,但不能防止同网段攻击者阻断下载或造成拒绝服务。
- 尚未接入 HTTPS 域名、证书、灰度发布、多渠道、增量更新或正式 Authenticode 签名。
- 服务端没有回滚命令;发布后需要回退时必须发布一个更高版本,不能覆盖已发布版本目录。
- `resources/updates/public-key.pem` 已进入 Electron `extraResources`;正式包仍需做一次资源存在性验收。
- 推荐 `0.0.5` 安装包的 Authenticode 状态本轮未复验;真实性目前以 Ed25519 签名清单、
文件大小和 SHA-256 为已验证边界。
- 本轮没有执行真实 `0.0.4 → 0.0.5` 覆盖升级,也没有在独立用户目录完成完整安装/卸载验收。
- 专用 NSIS 运行时探针没有执行;安装器写入、覆盖、重启和卸载行为仍是测试缺口。
+49
View File
@@ -0,0 +1,49 @@
# 升级模块落地(2026-10-20)
## 真实升级反馈:ASAR 标记误判
用户在 `0.0.3 → 0.0.4` 测试中实际遇到 `NSIS 安装标记不是文件`。物理安装文件正常;原实现使用 Electron patched `fs.stat`,将 `resources/app.asar` 当作虚拟目录。此前 Node 后端测试和未调用安装器的启动/下载验证没有覆盖这个语义差异。
修复通过 `physical-files.ts` 的 Electron `node:original-fs` 适配器执行物理 `stat/realpath`,由生产更新入口注入。没有全局关闭 ASAR,也没有移除 `isFile`、注册表或路径校验。真实 Electron 只读测试 `node scripts/verify-updates-installer-marker.cjs` 已通过:当前真实安装目录校验成功,真实目录 marker 和错误 registry 仍被拒绝;未启动安装器、未修改用户安装。报告 `.tmp/updates-installer-marker/2026-10-20T10-23-07-489Z/report.json`。
修正版为 `0.0.5`。旧版 `0.0.3/0.0.4` 的运行中检查器无法通过服务器目标包自我修复,需要手动覆盖安装一次修正版后,再测试后续自动升级。
`0.0.5` 已构建并发布,237106337 字节,SHA-256 `a3b8ece96077af81f9c8c676cb7d0d71f16a5d73332bc48b865c5cba7e92fd0d`。最终 ASAR 已核对包含物理文件适配器;生产包隔离启动与正式 IPC、服务器签名清单及完整下载校验通过,报告 `.tmp/updates-live/2026-10-20T10-28-49-489Z/report.json`。没有替用户执行安装或重启,真实覆盖安装仍待用户继续验证。
## 实现与边界
- Windows x64、Electron + NSIS 按用户安装;账号菜单、关于页与侧栏共享真实更新状态。
- 启动 10 秒后检查,此后每 5 分钟检查;发现更新后后台下载,已忽略版本不自动下载。
- 检查中、下载进度、就绪、失败重试、按版本忽略/折叠、手动下载及安装诊断日志已接通。
- 原版参考为 WorkBuddy 5.8.3,ASAR SHA-256 `3e50fcb5b060deba4f1ea9a8c293cf955bf12d7e52d971985de914350afd9077`;主要 REA Evidence 为 `ev_6f16b9ef02d9c5fb142e7d686ea8caeeec79797b621d615aaa532b49b65d7ad9`(renderer)与 `ev_e41b1e44cb0c430be5d8caa14b4e3a4710a6925e0bdff870d42ffa73b5e1a5c8`(Windows 更新模块)。本轮复用历史静态 REA 结论,没有通过其他渠道查看原版。
- 强制升级、商店/企业分支、启动 splash 与架构纠正仍在后续完整复刻清单中。本轮普通更新交互不是完整 1:1 验收;缺少原版全状态运行态布局/动效/像素证据。
## 安全与退出
- 完整 payload 使用独立 YCode Ed25519 密钥签名;客户端先验签再采用版本、说明、大小、下载地址与哈希。公钥随包分发;私钥仅在仓库外保存。
- 同源、固定版本目录、严格 SemVer、响应体上限、超时、大小与 SHA-256 校验;断点续传检查 Content-Range;缓存和安装前再次校验。
- 多窗口 IPC 仅接受主窗口/设置窗口的主 frame;渲染层不接收 Node 能力或安装路径参数。
- 用户确认前不启动安装器。确认后暂停新任务/终端创建,停止调度并取消、等待当前任务落库;该准备阶段不销毁全局 Claude 内核/MCP。
- 准备超时后,不会在旧 stop 尚未结束时恢复服务;安装标记写入或 spawn 失败后允许恢复。
- 安装器成功 spawn 是不可回退提交点;后端拒绝重复安装/检查/忽略,不再恢复后台任务。随后有界清理资源并退出,避免已启动的安装器和恢复后的工作并存。
- NSIS 等待指定 PID 后检查残留实例;不使用进程名批量结束其他实例。真实执行行为以专项安装 QA 结果为准。
- 安装位置绑定当前用户注册表 GUID、当前 EXE、卸载器与 app.asar,支持用户选择的目录;便携解压/开发模式不执行覆盖安装。
- 记录目标版本,下次启动仍低于目标则提示升级未完成。安装器执行失败不会被误报为已升级。
## 验证
- `npm run test:updates`:后端 14 组;运行时门禁与生命周期专项;发布工具 12 组;发布清单与真实后端验签/路径契约检查通过。
- `npm run build`:完整主进程、preload、renderer 构建和 Node/Web 类型检查通过。
- `npm run test:updates:ui`:真实 Electron 主/设置窗口、正式 preload、注入确定性 updates IPC,验证渲染交互和跨窗口状态;最终报告 `.tmp/updates-ui-ZnQVYZ/report.json`,无 renderer 错误。该验证不等价于真实下载或安装。
- `npm run test:chat`:消息顺序、聊天状态、流式提交旧回归通过。
- `0.0.2` 的真实生产包隔离启动与 HTTP 下载验证通过;报告 `.tmp/updates-live/2026-10-20T09-43-52-041Z/report.json`。前代版本号为注入模拟,安装器调用为零,不是实际版本升级。
- 审查发现并修复安装失败后内核永久关闭、超时清理继续破坏恢复、确认期间并发忽略、spawn 后回滚、NSIS 错误码与残留进程检查问题;修正版内部版本递增为 `0.0.3`,不覆盖不可变 `0.0.2` 发布。
- `0.0.3` 已构建并发布,安装包 `release/YCode-0.0.3-Setup-x64.exe`,237105798 字节,SHA-256 `6f7565fcc152a99899bf74a795c8e91417516980f143fb9fccb61bde2b490200`。
- `0.0.3` 真实生产包隔离启动与正式 preload IPC 检查通过(idle → checking → up-to-date);真实服务器签名清单及全量安装包下载/哈希通过,模拟旧版本最终 ready/100。报告 `.tmp/updates-live/2026-10-20T10-02-05-040Z/report.json`。安装器/安装回调调用均为零;真实用户注册表与安装 EXE 指纹前后未变。
- 完整 NSIS 真机覆盖安装、原生确认取消、安装后重启/版本回查,以及等待宏的不存在 PID/睡眠 PID/第二实例/拒绝访问动态探针仍未执行。独立 QA 安装方案仅完成设计,没有构建或安装 fixture;不能把单元、渲染或真实下载测试当作已完成安装验收。
## 发布与使用
服务器为 `local001`,客户端更新入口为 `http://192.168.10.6/ycode/releases/latest.json`,手动下载页为 `http://192.168.10.6/ycode/releases/`。SSH 仅用于发布维护,客户端不需要 SSH 凭据。HTTP 限内网使用;真实性依赖内置公钥,不能防止网络阻断/旧清单重放导致的更新延迟。
参见 [升级发布说明](升级发布说明-2026-10-20.md)。`0.0.1` 未包含 updater,必须先关闭旧 YCode 并手动安装首个安全验收完成的基线包,之后才有自动更新能力。安装包尚未 Authenticode 签名,系统可能显示 SmartScreen 警告。没有替换或重启当前用户安装版,也没有修改其配置/会话或提交 Git。
@@ -0,0 +1,53 @@
# 技能详情、安装与使用链路修正(2026-10-20)
## 本轮实现
### 头像与详情
- 核对 GitHub 官方仓库 owner 为 `tt-a1i`(ID `53142663`),下载官方头像并随 renderer 打包,不在运行时请求 GitHub。来源、URL 和文件哈希见[头像来源](推荐技能头像来源-2026-10-20.md)。
- 推荐卡片、详情和带推荐来源的已安装卡片/详情统一显示头像,普通同名本地导入不套用。图片损坏降级首字母;未修改技能包、原 ZIP 哈希或内容 digest。
- `SkillDetailShell` 统一推荐与已安装详情:头像/标题/主操作、可展开简介、真实元信息及按内容收缩的正文。移除仅展示来源却沿用正文最小高度导致的大块空白;完整 commit、包路径和哈希折叠展示,不编造安装数、使用数或市场 ID。
- 已安装技能说明仍读取真实本地 SKILL.md。未安装时只展示内置精选简介,不提前下载整个包。
### 全局弹层与安装
- 根因:能力中心 `isolation:isolate` 限制了内部 fixed 遮罩的层叠范围,而侧栏 sticky 标题拥有独立层级。
- 详情、导入及卸载确认使用 body 级 `SkillModalPortal`,保留源主题变量和 CSS 作用域;背景设为 inert / aria-hidden 并在退出后按原值恢复。没有降低全部侧栏层级。
- 共用焦点栈,Esc 只关闭顶层,Tab/ShiftTab 包含折叠区 summary 并跳过闭合 details 内的不可见按钮,关闭后恢复焦点。技能弹层存在时不响应背景的应用导航快捷键。
- 推荐卡片和详情共用操作状态。首次安全确认在原详情内展示;下载、校验、提交、失败、取消和成功均原位呈现,提交阶段不可取消。关闭详情不取消已确认安装,重新打开继续显示同一操作。
- 成功按钮切换为“去使用”,成功提示也提供相同入口;移除列表底部重复的永久完成面板。失败可明确重试,覆盖确认仍展示当前来源、本地修改及启停保留。
- 沿用操作 ID / 单调时间戳、防重复与跨窗口互斥。GitHub 官方 API、固定提交及 ZIP 安全/事务校验保持不变。
### 新草稿与斜杠
- 管理页各“去使用”入口统一调用 App 的新任务 intent:继承工作空间和模型,清除原任务专家绑定;带目标 owner 的 intent 将技能加入新草稿并聚焦,不发送、不创建持久会话。已有会话草稿不被覆盖。
- 无效或仅模型调用技能不提供可执行的使用动作;禁用技能显示“启用后使用”,等待启用和状态确认成功后再跳转。
- 斜杠问题根因:只修改 `text`,旧 `userContent` 在切换至 Slate 时重建了斜杠;菜单因检测到旧文本重新出现。
- `withCapabilitySelection` 将技能选择、纯文本和结构化草稿合并为同一次草稿更新。斜杠采用 add-once 语义,加号菜单仍为多选 toggle。
- 已有 Slate 编辑器通过文本叶事务删除触发区间,PointRef 保留光标与引用的相对位置,不删除附件或技能标签;恢复原生焦点以处理移除 picker 后 Slate 焦点缓存仍为 true 的情况。选择后可以直接继续输入。
- Esc 记录当前触发文本的关闭状态,只有用户编辑后才重新检测;中文输入法组合期间不弹菜单。
## 接口与文件边界
- SkillsApi 不变;`RecommendedSkill.avatarResource` 新增可选受限标识 `archify-owner`,不是任意本地路径或 URL。
- 输入区内部回调增加可选 `CapabilitySelectionIntent`(slash/menu 来源及已消费的 text/userContent);ReferenceContentEditor 增加删除文本区间的宿主方法。
- 新增公共详情/portal/头像及源文件选择 helper。未修改 WorkBuddy 安装文件,也未改变技能下载的主进程信任边界。
## 原版依据与限制
- 本轮按用户选择仅做原版只读源码调查,没有启动额外 WorkBuddy 实例或安装原版技能。
- WorkBuddy 5.8.2 已保存 REA 证据 `ev_717fa5e846e64d67ac19c8925d121bff632c0d3aefb3603b10858559cabb2dcb` 的恢复源码中,`ActionRow` 随安装状态切换安装/使用;`toastSkillInstallSuccess` 带使用动作;`useTrySkill` 普通分支进入新任务草稿。
- 本次 REA 请求 `127.0.0.1:9223` 返回 endpoint_unreachable。当前安装包 SHA-256 为 `3e50fcb5b060deba4f1ea9a8c293cf955bf12d7e52d971985de914350afd9077`,与历史技能证据不同;REA inventory 记录 45 项完整性矛盾,历史文件路径不能直接选中。此次 inventory 仅作身份/限制记录,不据此宣称分析了当前版本的详情实现。父 Evidence:`ev_1908879a0d710764cd53bd08c36bd9d70d07a004c08ee8f711ca6f849a3b6244`。
- 因此是“历史 REA 源码行为依据 + YCode 实现及动态验收”,不是当前原版同场景动态或完整 1:1 验收。安装完成也不意味着 Archify 的执行依赖和全部绘图能力已验证。
## 验证
- `npm run test:capability-slash:ui`:真实 ComposerControls / Slate / 生产选择 helper 的隔离 Electron 测试 **21 项通过**,`.tmp/capability-slash-3UD46Z/report.json`。覆盖鼠标、Enter、裸斜杠、句中光标及后文、已有与重复技能、继续输入、多行、附件、Esc、IME;零 renderer 错误。
- `npm run test:recommended-skills:ui`:**48 项通过**,`.tmp/recommended-skills-ui-LGtBA4/report.json`。受控宿主覆盖确认与同一详情 DOM、成功入口、取消、关闭再打开保留操作、提交不能取消、禁用后启用使用、冲突替换、错误重试、外部操作、过期事件、Tab 焦点和深/浅色窄窗。不是实际网络安装。
- `node scripts/test-skills-ui.mjs`:既有管理/真实隔离文件系统与受控 picker **37 项通过**,最终单独复跑 `.tmp/skills-ui-10SOjj/report.json`;并发运行曾发生一次坐标点击未切换批量态的测试失败,独立复跑通过,不将其隐藏为从未失败。
- `npm run test:recommended-skills`:后端 **66 项通过**,包括离线缓存的 Archify 实包组;没有更改下载与导入校验策略。
- `npm run test:composer-capabilities`、`npm run test:conversation`、`npm run test:persistence`、`npm run test:sessions` 均通过。输入区回归日志 `.tmp/skills-flow-composer-regression.log`。
- 类型检查及完整构建通过,日志 `.tmp/skills-flow-build.log`。
- 正式 main/preload/renderer 真实 GitHub 下载隔离验收 **20 项通过**,最终报告 `.tmp/recommended-app-RFA1cK/report.json`;包含 body 级遮罩、背景 inert、侧栏命中测试、全窗深色/720px 截图、离线头像、原位安装、新草稿引用、原会话草稿恢复及无持久会话新增。主线程查看同批实现的全窗/窄窗、安装成功和输入区截图。
所有技能安装、启停和草稿测试均使用 `.tmp` 隔离数据,未操作用户技能库、执行技能脚本或请求模型。保留既有修改,未提交、发布或重启用户安装版。
+14
View File
@@ -1,5 +1,19 @@
# YCode 接手状态(2026-10-15)
> 2026-10-20 23:53(Asia/Shanghai)0.0.7已发布:[发布记录与验证](升级发布说明-2026-10-20.md)。包含本轮推荐技能/GitHub安装、头像、详情遮罩、原位安装/去使用、新任务草稿、斜杠菜单及既有技能/MCP输入区接入。local001 latest从0.0.6升至0.0.7,历史版本保留;237351255字节,SHA-256 `506768934c92979ba38c4ab922b58d2de98b590a125fd1a5dacd8dadd2ddaff3`。升级回归、发布17项、完整构建、包内内容、公钥、签名清单、SSH/HTTP、正式生产EXE隔离启动与真实全量下载校验通过,报告 `.tmp/updates-live/2026-10-20T15-53-57-094Z/report.json`。安装包Authenticode为NotSigned;仅更新清单Ed25519签名。未执行安装器、覆盖升级或重启用户安装版,未Git提交。
> 2026-10-20 技能详情/安装/使用链路修正:[实现与验证](技能详情安装与使用链路修正-2026-10-20.md)。Archify 改用本地打包的 GitHub owner 头像;推荐和已安装共用紧凑详情,body portal + inert 修复侧栏 sticky 标题穿透。安装确认、进度及成功“去使用”原位衔接,成功提示也可用;使用进入新任务草稿、保留旧会话草稿、不自动发送。斜杠选择原子消费 text/userContent,Slate 文本事务保留引用及光标、修复原生焦点,Esc/IME/重复选择和加号多选语义区分。真实 Slate 21项、推荐UI48项、既有技能管理37项、后端66项、最终正式Electron真实GitHub安装/遮罩/草稿保留20项及类型检查、构建、输入区/对话/持久化/会话回归通过;最终报告 `.tmp/recommended-app-RFA1cK/report.json`。原版仅依据历史5.8.2 REA源码,当前runtime不可达,不是当前原版动态1:1验收。隔离验证,未改用户技能、执行包内脚本、请求模型、提交、发布或重启安装版。
> 2026-10-20 推荐技能 GitHub 下载修复:用户网络对 raw.githubusercontent.com 直链 ECONNRESET,改用 GitHub 官方 Contents API 的 raw 响应,仍固定完整 commit、原 ZIP 大小及 SHA-256,不引入镜像或修改代理。后端66项、官方API真实下载实包48项、正式Electron隔离安装与引用11项、类型检查及构建通过;重启仓库dev加载修复,未动安装版或用户技能。见[修复与证据](推荐技能GitHub安装-2026-10-20.md#github-直链连接失败修复)。
> 2026-10-20 内置推荐技能与 GitHub 安装:[实现与验证边界](推荐技能GitHub安装-2026-10-20.md)。技能页推荐接固定 Archify 3.2.0 包,主进程下载、哈希/元数据校验、取消、覆盖确认及原子回滚,来源同次登记,安装/启停/卸载实时刷新输入区目录。后端62项、实包48项、推荐UI37项(含深色/窄窗/外部操作/过期事件)、既有技能UI37项、最终生产Electron真实GitHub下载与输入框回填11项,以及类型检查、完整构建和相关回归通过;已看真实截图,零renderer错误。仅验证文件安装与引用,不宣称Archify全部运行能力或完整1:1。隔离验收,未改用户技能、执行包内脚本、提交、发布或重启用户安装版。
> 2026-10-20 MCP/技能与对话输入区接入:[实现与验证边界](MCP技能与输入区接入-2026-10-20.md)、[原生追加/压缩协议](Claude追加与压缩协议验证-2026-10-20.md)。启用宿主管理能力桥接、加号/斜杠引用、统一输入区、持久追加队列与不打断的原生立即发送;支持下一请求模型设置、真实上下文统计和原生压缩。完整构建、相关回归及真实 Electron 主进程/预加载/渲染器+本地 Claude SDK/CLI 回环追加验证通过,主线程已检查队列和最终历史截图。原版 REA 运行时不可达,团队/后台任务来源未完整接线,不能宣称完整 1:1;真实外部服务和故障注入仍有边界。保留升级模块等既有修改,未提交、发布、安装或重启用户安装版。
> 2026-10-20 真实升级 ASAR 标记误判修复:用户 `0.0.3 → 0.0.4` 报“NSIS安装标记不是文件”,已确认 Electron patched fs 将正常物理 `app.asar` 显示为虚拟目录。改为由生产入口注入 `node:original-fs` 的物理 stat/realpath,保留全部安装身份检查。真实Electron只读 marker 回归、后端14组、发布与契约、完整构建通过;`0.0.5` 已构建发布,生产包隔离启动及真实全量下载校验通过,未执行安装。旧3/4检查器不能靠服务器目标包修复,须手动覆盖安装一次5;详细记录见[升级模块落地](升级模块落地-2026-10-20.md#真实升级反馈asar-标记误判)。
> 2026-10-20 普通升级模块落地:[实现与验证边界](升级模块落地-2026-10-20.md)、[发布与密钥说明](升级发布说明-2026-10-20.md)。账号菜单/关于页/侧栏接共享更新状态,签名清单、后台下载/续传、大小与哈希校验、退出确认、任务收尾、NSIS 定点等待和安装失败回查已实现;更新源为 local001 的独立 `/ycode/`,最终修正版 `0.0.3` 已构建并发布,不覆盖不可变 `0.0.2`。后端14组、门禁/生命周期、发布12组、真实后端契约、NSIS静态安全检查、真实Electron UI21项(0renderer错误)、聊天回归及完整构建通过。完整真实NSIS覆盖安装/重启尚未验收,不是完整1:1;强制升级/商店/企业/启动splash继续待补。未替换当前用户安装版;原0.0.1无更新器,需先手动安装基线。未提交Git。
> 2026-10-20 文件所有权租约移除:[范围与验证](文件所有权租约移除记录-2026-10-20.md)。按用户要求删除子智能体调度器的 `leases`、`rootIntents`、`rootLeases` 和 root 等待队列;主智能体工具、子智能体工具以及父子同文件写入不再因所有权被拒绝或串行。保留 `spawn_agent` 的声明文件列表校验,`write` 模式仍只能写自己声明的路径。`npm run typecheck`、`npm run test:subagents`(31 项)和完整 `npm run build` 通过。移除后并发写同一文件按工具实际执行顺序生效,无跨工具锁;需要原子一致性时由调用方或更高层流程处理。
> 2026-10-20 自建 AppContainer 沙盒移除:[范围与验证](沙盒移除记录-2026-10-20.md)。按用户要求删除 `src/main/sandbox`、`native/sandbox`、`resources/sandbox`、`sandbox_workspace`/`sandbox_git` 工具、相关构建/测试/维护脚本、打包 extraResources 和忽略规则;保留 Electron `webPreferences.sandbox` 渲染进程隔离及“默认权限/完全访问”权限界面。`npm run typecheck` 与 `npm run build` 通过,`out/main/index.mjs` 不再包含 `windows-appcontainer`、`sandbox_workspace`、`sandbox_git`、`ycode-sandbox`;`release/win-unpacked/resources/sandbox` 已清理。本仓库不再提供自建 OS 级沙盒;此前所有沙盒验收结果只作为历史记录,不代表当前代码能力。
@@ -0,0 +1,62 @@
# 内置推荐技能与 GitHub 安装(2026-10-20)
## 实现范围
- 技能页“推荐”改用随应用发布的精选目录;首发 Archify。SkillHub、套件仍明确未接入,不伪装联网市场。
- 复用现有技能卡片、详情及主题;提供安装确认、下载/校验/安装进度、取消、错误重试、查看已安装和使用技能。替换确认展示当前来源、本地修改及启停状态。
- 主进程只接受推荐 ID 和操作 ID/覆盖确认,不接受渲染器提供下载地址、哈希或安装目录。
- 固定 HTTPS GitHub 官方来源,最多三次白名单域名重定向,20 MB 下载上限、120 秒下载超时;校验 SHA-256、名称、版本、许可证,再复用 ZIP 安全检查及暂存/备份/回滚事务。
- 全进程最多一个推荐安装,不排队、不自动重试。下载/校验可取消,原子提交不可取消;停服或退出中止尚未提交的操作。启动清理可确认所属进程已退出的遗留临时目录,不续装。
- 来源元数据和技能登记同次提交;旧登记兼容。同名导入、不同版本或修改后的技能须明确替换,保留启停状态;同来源同版本且完整的安装直接返回现有记录。手动导入覆盖会清除推荐来源。
- 安装、卸载、启停广播变更,刷新推荐、已安装列表及输入框能力目录,无需重启。
## 固定包
| 字段 | 值 |
| --- | --- |
| 推荐 ID / 名称 | archify |
| 仓库 | tt-a1i/archify |
| commit | 25cf37080fc8b1dbe758a4efc3935b55f3327083 |
| 包路径 | archify.zip |
| SHA-256 | 70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5 |
| 下载字节数 | 5,271,959 |
| ZIP 文件数 / 展开字节数 | 200 / 17,812,933 |
| 许可证 / 显示版本 | MIT / 3.2.0 |
实包 `SKILL.md` 中的 `metadata.version` 为 `3.2`,`package.json` 为 `3.2.0`;仅将数字形式的 major.minor 与 major.minor.patch 归一比较,未改用最新分支。包含 101 个 JS/MJS 脚本;安装不执行包内脚本,也不读取其更新地址。
## 代码入口
- `src/main/capabilities/skill-recommendations.ts`:版本化精选目录。
- `src/main/capabilities/recommended-skills.ts`:下载、安全边界、进度和生命周期;注入端口仅作测试隔离。
- `src/main/capabilities/skills.ts`:来源保存、嵌套版本解析、现有导入器复用及提交前冲突检查。
- `src/shared/capabilities.ts`、`src/main/ipc.ts`、`src/preload/index.ts`:目录、安装、取消、当前操作快照、进度及变更事件。
- `src/renderer/src/capabilities/RecommendedSkills.tsx`:卡片与安装 UI,使用操作 ID 和单调时间戳抵御旧状态覆盖。
- `src/renderer/src/workbuddy/composer/useCapabilityCatalog.ts`:变更后刷新输入框目录。
## 已验证
- `npm run typecheck` 和完整 `npm run build` 通过;构建日志 `.tmp/recommended-skills-build.log`。
- `npm run test:recommended-skills`:62 项通过;包含模拟网络错误/重定向/超限/超时/断连/取消,真实隔离技能库中的同名覆盖、禁用保留、来源清除、安装中被修改、登记写入失败回滚与停服恢复。实包组使用本地缓存,没有缓存会明确 SKIP。
- `npm run test:recommended-skills:ui`:最终 37 项通过,`.tmp/recommended-skills-ui-ED7GR0/report.json`;使用隔离 Electron 和受控宿主,覆盖其他窗口操作互斥、过期事件不回退终态、深色主题及 720px 窄窗无横向溢出。主线程检查深色/窄窗截图;不冒充真实网络安装或完整应用壳的深色验收。
- `node scripts/test-skills-ui.mjs`:既有技能管理 37 项通过,`.tmp/skills-ui-NeLZZW/report.json`。
- `npm run test:recommended-skills:package`:实包隔离审计 48 项通过,最终复跑 `.tmp/archify-package-ZtYztG/report.json`(使用已下载固定 ZIP)。
- 最终构建执行 `npm run test:recommended-skills:app`:11 项通过,`.tmp/recommended-app-G08DdX/report.json`。使用正式 main/preload/renderer、真实固定 GitHub 下载与隔离目录,确认阶段事件、正文/CLI 资源、来源、幂等、禁用保留,以及“使用技能”回填真实输入框;零 renderer 错误。主线程检查已安装及输入框截图。
- `npm run test:composer-capabilities`、`npm run test:persistence`、`npm run test:sessions`、`npm run test:conversation` 通过。
## 明确边界
- 固定哈希说明下载内容一致,不构成技能代码安全审计;本次没有执行 Archify 命令、准备其运行依赖或验证全部绘图能力。
- 不支持任意地址、私有仓库、自动更新或后台安装队列;后续推荐通过目录条目随 YCode 发布。
- 所有验收使用隔离技能库,没有改动用户现有技能,也没有请求模型 API。未提交 Git、发布安装包或重启用户安装版;保留原有 MCP、输入区和升级模块等工作区修改。
- 本次不新增 WorkBuddy 原版采集或完整 1:1 结论。
## GitHub 直链连接失败修复
用户实际 dev 安装报 `fetch failed`;同机重现 `raw.githubusercontent.com` 返回 `ECONNRESET`。改用 GitHub 官方 Contents API,完整 commit 放在 `ref` 参数,通过 `Accept: application/vnd.github.raw+json` 获取 ZIP 原始字节。实测 5,271,959 字节及登记哈希完全一致,不是 JSON 元数据,也未切换分支或第三方代理镜像。
下载仅放行 `api.github.com` 和 `raw.githubusercontent.com` 的 HTTPS 请求/跳转,原有大小、哈希、ZIP 安全、取消和事务保护保持不变。不自动重试或改变 Windows 代理配置。连接失败提供网络/代理提示,API 限流提供稍后重试提示;未加入 token 或私有仓库认证。
后端新增官方 API、固定提交、raw Accept 和限流回归,共 66 项通过。类型检查及完整构建通过(`.tmp/recommended-github-api-build.log`)。实包审计通过官方 API 真实下载 48 项通过:`.tmp/archify-package-kTQcPp/report.json`,未使用本地缓存。
最终正式 main/preload/renderer 隔离验收 11 项通过(`.tmp/recommended-app-kwzDyE/report.json`),实际经新 API 完成安装及输入框引用回填,零 renderer 错误。重启了本仓库的 dev 进程以加载修复,未重启安装版、修改系统代理或改动用户技能。
@@ -0,0 +1,10 @@
# Archify 推荐头像来源
- 核对接口:GitHub 官方 `GET https://api.github.com/repos/tt-a1i/archify`。
- 仓库 owner:`tt-a1i`,GitHub 用户 ID `53142663`,类型 `User`。使用仓库所有者头像,不推断所有 contributors 都是维护者。
- 头像 URL:`https://avatars.githubusercontent.com/u/53142663?v=4&s=192`。
- 响应类型 `image/jpeg`,9,461 字节;本地文件 `src/renderer/src/capabilities/assets/archify-owner-avatar.jpg`。
- SHA-256:`72e7c333a86d7954786fbb6782a6090ff1524964db50e7f293c6ba77f46ab85e`。
- 随应用打包,页面不实时请求 GitHub;加载失败降级为首字母。推荐及带 Archify 推荐来源的已安装技能使用此图,普通同名本地技能不自动套用。
- 图片只用于标识公开仓库所有者,不作为 YCode 自有商标或已获头像许可的声明;后续若收到权利人请求应更换或移除。
- 未注入技能 ZIP、未改动包哈希或安装内容 digest。推荐记录的可选 `avatarResource` 为受限资源标识,不接受任意本地路径。
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "ycode",
"version": "0.0.1",
"version": "0.0.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ycode",
"version": "0.0.1",
"version": "0.0.7",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.3.304",
"@anthropic-ai/sdk": "^0.135.0",
+22 -2
View File
@@ -1,11 +1,19 @@
{
"name": "ycode",
"version": "0.0.1",
"version": "0.0.7",
"private": true,
"description": "YCode - 个人 AI Agent 桌面应用(Electron)",
"author": "YCode",
"main": "./out/main/index.mjs",
"scripts": {
"test:capability-slash:ui": "node scripts/test-capability-slash-ui.mjs",
"test:recommended-skills": "esbuild scripts/recommended-skills-backend-regression.ts --bundle --platform=node --format=esm --external:esbuild --outfile=.tmp/recommended-skills-backend-regression.mjs --log-level=warning && node .tmp/recommended-skills-backend-regression.mjs",
"test:recommended-skills:ui": "node scripts/test-recommended-skills-ui.mjs",
"test:recommended-skills:app": "node scripts/verify-recommended-skills-app.cjs",
"test:recommended-skills:package": "node scripts/test-recommended-archify-package.mjs",
"test:composer-capabilities": "node scripts/run-composer-capabilities-regression.mjs",
"test:claude-append": "node scripts/build-claude-append-probe.mjs && node .tmp/claude-append-protocol-probe.mjs",
"test:claude-append:app": "node scripts/verify-claude-append-app.cjs",
"dev": "electron-vite dev",
"dev:renderer": "electron-vite dev --rendererOnly",
"build": "npm run typecheck && electron-vite build",
@@ -28,6 +36,13 @@
"icon:build": "node scripts/gen-icon.mjs",
"dist": "npm run build && electron-builder --win dir",
"dist:installer": "npm run build && electron-builder --win nsis",
"release": "node scripts/updates/release.mjs",
"test:release": "node scripts/updates/release-selftest.mjs",
"test:updates": "esbuild scripts/update-backend-regression.ts scripts/update-maintenance-regression.ts --bundle --platform=node --format=esm --out-extension:.js=.mjs --outdir=.tmp/update-tests --log-level=warning && node .tmp/update-tests/update-backend-regression.mjs && node .tmp/update-tests/update-maintenance-regression.mjs && node scripts/updates/selftest.mjs && node scripts/updates/contract-selftest.mjs && node scripts/updates/nsis-safety-regression.mjs",
"test:updates:ui": "node scripts/verify-updates-ui.cjs",
"updates:key": "node scripts/updates/init-key.mjs",
"updates:prepare": "node scripts/updates/prepare-release.mjs",
"updates:deploy": "node scripts/updates/deploy.mjs",
"verify:ui": "node scripts/verify-ui.mjs",
"wb:capture": "node scripts/wb-capture.mjs 9223 .tmp/wb-capture",
"test:media": "esbuild scripts/mermaid-regression.ts --bundle --platform=node --format=esm --packages=external --jsx=automatic --loader:.css=empty --outfile=.tmp/media-tests/mermaid-regression.mjs --log-level=warning && node .tmp/media-tests/mermaid-regression.mjs && esbuild scripts/image-renderer-regression.tsx scripts/path-renderer-regression.tsx --bundle --platform=node --format=cjs --jsx=automatic --loader:.css=empty --out-extension:.js=.cjs --outdir=.tmp/media-tests --log-level=warning && node .tmp/media-tests/image-renderer-regression.cjs && node .tmp/media-tests/path-renderer-regression.cjs",
@@ -140,6 +155,10 @@
"node_modules/@anthropic-ai/claude-agent-sdk/**"
],
"extraResources": [
{
"from": "resources/updates/public-key.pem",
"to": "updates/public-key.pem"
},
{
"from": "resources/model-metadata",
"to": "model-metadata",
@@ -170,12 +189,13 @@
],
"win": {
"icon": "build/icon.ico",
"artifactName": "YCode-v0.01-Setup-${arch}.${ext}",
"artifactName": "YCode-${version}-Setup-${arch}.${ext}",
"target": [
"dir"
]
},
"nsis": {
"include": "build/installer.nsh",
"oneClick": false,
"perMachine": false,
"allowToChangeInstallationDirectory": true,
+41
View File
@@ -0,0 +1,41 @@
# YCode Update Manifest Contract v1
The release tooling and updater backend use this exact document:
```json
{
"payload": {
"schemaVersion": 1,
"version": "0.0.2",
"publishedAt": "2026-10-20T10:00:00.000Z",
"notes": {
"zh": "更新说明",
"en": "Release notes"
},
"platforms": {
"windows-x64": {
"url": "http://192.168.10.6/ycode/releases/versions/0.0.2/YCode-0.0.2-Setup-x64.exe",
"size": 123456789,
"sha256": "64-lowercase-hex-characters"
}
}
},
"signature": "base64-encoded-64-byte-ed25519-signature"
}
```
Rules:
- The Ed25519 signature covers exactly `Buffer.from(JSON.stringify(payload), "utf8")`.
- Payload property insertion order is part of the contract and must be preserved.
- `publishedAt` is canonical UTC ISO-8601 with millisecond precision.
- `version` is strict SemVer. Build metadata does not establish newer precedence.
- `sha256` is lowercase hexadecimal; `size` is the exact installer byte length.
- The artifact URL is absolute HTTP(S), has no query, fragment or credentials, and is exactly
`<base>/versions/<version>/<safe-installer-name>.exe`.
- The client and release tooling require the artifact origin to match the manifest origin.
- `latest.json` is served at `<base>/latest.json`; artifacts are immutable version directories.
- `resources/updates/public-key.pem` is the SPKI PEM trust anchor. Private keys never enter the
repository, the server, build artifacts or logs.
Default base URL: `http://192.168.10.6/ycode/releases/`
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PUBLIC KEY-----
MCowBQYDK2VwAyEAxUeHhwDpkjDtIDI9EliFgdmMjahN5Mgq/2cX2+gLkKY=
-----END PUBLIC KEY-----
+11
View File
@@ -0,0 +1,11 @@
import { build } from 'esbuild'
await build({
entryPoints: ['scripts/claude-append-protocol-probe.ts'],
outfile: '.tmp/claude-append-protocol-probe.mjs',
bundle: true,
platform: 'node',
format: 'esm',
external: ['@anthropic-ai/claude-agent-sdk'],
logLevel: 'warning'
})
@@ -8,6 +8,9 @@ await build({
format: 'esm',
external: ['@anthropic-ai/claude-agent-sdk'],
logLevel: 'warning',
banner: {
js: `import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);`
},
plugins: [{
name: 'claude-kernel-electron-stub',
setup(plugin) {
@@ -19,6 +22,7 @@ await build({
getPath: (name) => name === 'userData' ? process.env.CLAUDE_TEST_USER_DATA : process.cwd(),
getAppPath: () => process.cwd()
};
export const safeStorage = { isEncryptionAvailable: () => false };
`
}))
}
@@ -0,0 +1,10 @@
import { build } from 'esbuild'
await build({
entryPoints: ['scripts/request-settings-regression.ts'],
outfile: '.tmp/request-settings-regression.mjs',
bundle: true,
platform: 'node',
format: 'esm',
logLevel: 'warning'
})
+79
View File
@@ -0,0 +1,79 @@
import assert from 'node:assert/strict'
import { renderToStaticMarkup } from 'react-dom/server'
import {
CapabilityPicker,
capabilitySelectionKey,
detectCapabilitySlashTrigger,
filterCapabilityItems,
resolveCapabilityPickerKey,
type CapabilityPickerItem
} from '../src/renderer/src/workbuddy/composer/CapabilityPicker'
const items: CapabilityPickerItem[] = [
{ id: 'skill:review', name: '代码审查', description: '检查实现缺陷与风险', kind: 'skill' },
{ id: 'mcp-server', name: 'postgres', description: '查询本地数据库', kind: 'mcp' },
{ id: 'compact', name: 'compact', description: '压缩当前上下文', kind: 'command' }
]
const checks: string[] = []
const check = (name: string, actual: unknown, expected: unknown): void => {
assert.deepEqual(actual, expected, name)
checks.push(name)
}
check('empty query preserves registration order', filterCapabilityItems(items, ''), items)
check('filter matches names and IDs case-insensitively',
filterCapabilityItems(items, '/POST').map(item => item.id), ['mcp-server'])
check('filter matches descriptions', filterCapabilityItems(items, '上下文').map(item => item.id), ['compact'])
check('filter matches kind labels', filterCapabilityItems(items, '命令').map(item => item.id), ['compact'])
check('multiple terms must all match', filterCapabilityItems(items, '数据库 本地').map(item => item.id), ['mcp-server'])
check('selection identity includes kind', capabilitySelectionKey({ id: 'compact', kind: 'command' }), 'command:compact')
check('slash at input start is detected',
detectCapabilitySlashTrigger('/comp', 5, items), { start: 0, end: 5, query: 'comp' })
const independent = '请使用 /mcp'
check('independent slash token is detected',
detectCapabilitySlashTrigger(independent, independent.length, items), { start: 4, end: independent.length, query: 'mcp' })
check('plain slash opens the catalog when items exist',
detectCapabilitySlashTrigger('/', 1, items), { start: 0, end: 1, query: '' })
for (const value of ['https://example.com/a', 'src/renderer/file.ts', 'C:\\work\\file.ts', '/Users/me/file.txt', 'file:///tmp/a']) {
check('path or URL does not trigger: ' + value, detectCapabilitySlashTrigger(value, value.length, items), null)
}
check('unknown leading token is rejected when catalog is available',
detectCapabilitySlashTrigger('/Users', 6, items), null)
check('slash inside ordinary text is not independent',
detectCapabilitySlashTrigger('path/compact', 12, items), null)
check('IME composition suppresses Enter',
resolveCapabilityPickerKey({ key: 'Enter', isComposing: true, itemCount: 3, activeIndex: 1 }), { type: 'none' })
check('IME keyCode 229 suppresses selection',
resolveCapabilityPickerKey({ key: 'Enter', keyCode: 229, itemCount: 3, activeIndex: 1 }), { type: 'none' })
check('ArrowDown wraps', resolveCapabilityPickerKey({ key: 'ArrowDown', itemCount: 3, activeIndex: 2 }),
{ type: 'move', index: 0 })
check('ArrowUp wraps', resolveCapabilityPickerKey({ key: 'ArrowUp', itemCount: 3, activeIndex: 0 }),
{ type: 'move', index: 2 })
check('Home and End select boundaries',
[resolveCapabilityPickerKey({ key: 'Home', itemCount: 3, activeIndex: 2 }),
resolveCapabilityPickerKey({ key: 'End', itemCount: 3, activeIndex: 0 })],
[{ type: 'move', index: 0 }, { type: 'move', index: 2 }])
check('Enter selects the active item',
resolveCapabilityPickerKey({ key: 'Enter', itemCount: 3, activeIndex: 1 }), { type: 'select', index: 1 })
check('Escape closes', resolveCapabilityPickerKey({ key: 'Escape', itemCount: 3, activeIndex: 1 }), { type: 'close' })
check('Enter is ignored on an empty result', resolveCapabilityPickerKey({ key: 'Enter', itemCount: 0, activeIndex: 0 }),
{ type: 'none' })
const html = renderToStaticMarkup(
<CapabilityPicker items={items} selected={[{ id: 'skill:review', kind: 'skill' }]}
onSelect={() => undefined} onClose={() => undefined} />
)
check('SSR renders combobox and listbox', [html.includes('role="combobox"'), html.includes('role="listbox"')], [true, true])
check('SSR exposes type labels', ['技能', 'MCP', '命令'].every(label => html.includes(label)), true)
check('SSR marks selected identity', html.includes('aria-selected="true"'), true)
check('SSR renders descriptions', html.includes('检查实现缺陷与风险'), true)
check('SSR seeds external query',
renderToStaticMarkup(<CapabilityPicker items={items} selected={[]} onSelect={() => undefined}
onClose={() => undefined} query="compact" />).includes('data-kind="command"'), true)
check('SSR renders explicit empty state',
renderToStaticMarkup(<CapabilityPicker items={items} selected={[]} onSelect={() => undefined}
onClose={() => undefined} query="missing" />).includes('没有匹配的能力'), true)
console.log(JSON.stringify({ result: 'PASS', checks: checks.length }))
+2 -1
View File
@@ -124,7 +124,8 @@ async function main(): Promise<void> {
export const ipcMain = {
handle: (name, handler) => state.handlers.set(name, handler)
};
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }), showSaveDialog: async () => ({ canceled: true }) };
export const webContents = { getAllWebContents: () => [], fromId: () => undefined };
export const shell = { openPath: async () => '' };
export const powerSaveBlocker = { start: () => 1, stop: () => true, isStarted: () => false };
export class BrowserWindow {}
+47 -2
View File
@@ -92,7 +92,7 @@ function resetStore(): void {
})
}
function installFakeWindow(send: (payload: { sessionId: string; text: string }) => Promise<SendResult>): void {
function installFakeWindow(send: (payload: Parameters<typeof window.ycode.chat.send>[0]) => Promise<SendResult>): void {
const api = {
session: {
list: async () => [session],
@@ -718,7 +718,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise<void> {
const firstSend = useChatStore.getState().send('question')
assert.equal(useChatStore.getState().sending, true, 'sending should cover the pending IPC request')
assert.equal(useChatStore.getState().messages.length, 0, 'early events wait for the send result placeholder')
await useChatStore.getState().send('duplicate')
await assert.rejects(useChatStore.getState().send('duplicate'), /提交/)
assert.equal(sendCalls, 1, 'a second send must be ignored while the first send is pending')
response.resolve(result)
@@ -929,7 +929,32 @@ async function testParallelSessionsAndStaleLoads(): Promise<void> {
assert.equal(useChatStore.getState().messages[0].content, 'fast', 'stale session fetch cannot replace a newer selection')
}
async function testNativeSegmentBoundaryBeforeSendResponse(): Promise<void> {
resetStore()
const gate = deferred<SendResult>()
installFakeWindow(async () => gate.promise)
const sending = useChatStore.getState().send('root')
const runId = 'native-segment'
const root = sendResult(runId)
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 1, type: 'text', delta: 'root answer' })
const guide: ChatMessage = { id: 'guide-user', role: 'user', content: 'guidance', createdAt: 12, status: 'done' }
const answer: ChatMessage = { id: 'guide-assistant', role: 'assistant', content: '', createdAt: 13, status: 'streaming', requestId: runId }
useChatStore.getState().observeRun({ ...root, sessionId: session.id, sequence: 2, segmentBoundary: true,
assistantMessageId: answer.id, userMessage: guide, messages: [root.userMessage,
{ id: root.assistantMessageId, role: 'assistant', content: 'root answer', createdAt: 11, status: 'done' }, guide, answer] })
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 3, type: 'text', delta: 'guide answer' })
gate.resolve(root)
await sending
useChatStore.getState().applyEvent({ runId, sessionId: session.id, sequence: 4, type: 'done', content: 'guide answer', tools: [] })
const messages = useChatStore.getState().messages
assert.equal(messages.find(message => message.id === root.assistantMessageId)?.content, 'root answer')
assert.equal(messages.find(message => message.id === answer.id)?.content, 'guide answer')
assert.equal(messages.find(message => message.id === answer.id)?.status, 'done')
assert.equal(useChatStore.getState().running, null)
}
async function main(): Promise<void> {
await testNativeSegmentBoundaryBeforeSendResponse()
resetStore()
let inlinePayload: Parameters<typeof window.ycode.chat.send>[0] | undefined
installFakeWindow(async payload => { inlinePayload = payload; return sendResult('inline-layout') })
@@ -947,6 +972,26 @@ async function main(): Promise<void> {
installFakeWindow(async () => { referenceOnlySends++; return sendResult('skill-only') })
await useChatStore.getState().send('', { skillIds: ['skill'] })
assert.equal(referenceOnlySends, 2, 'a skill-only draft must reach IPC')
let capabilityPayload: Parameters<typeof window.ycode.chat.send>[0] | undefined
resetStore()
installFakeWindow(async payload => { capabilityPayload = payload; return sendResult('mcp-only') })
await useChatStore.getState().send('', { mcpServerIds: ['server'] })
assert.deepEqual(capabilityPayload?.mcpServerIds, ['server'], 'an MCP-only draft must reach IPC with its selected server')
resetStore()
installFakeWindow(async payload => { capabilityPayload = payload; return sendResult('compact-only') })
await useChatStore.getState().send('/compact', { command: 'compact' })
assert.equal(capabilityPayload?.command, 'compact', 'a command-only draft must reach IPC as a native command')
resetStore()
useChatStore.setState({ currentSessionId: null, messages: [], draftContext: {}, draftId: 7 })
const deferredResult = deferred<SendResult>()
installFakeWindow(async () => deferredResult.promise)
const optimisticSend = useChatStore.getState().send('', { skillIds: ['optimistic-skill'], mcpServerIds: ['optimistic-server'] })
const optimisticUser = useChatStore.getState().messages.find(message => message.role === 'user')
assert.deepEqual(optimisticUser?.skills?.map(item => item.id), ['optimistic-skill'], 'the optimistic user message must retain selected skill metadata')
assert.deepEqual(optimisticUser?.mcpServerIds, ['optimistic-server'], 'the optimistic user message must retain MCP selection metadata')
deferredResult.resolve(sendResult('optimistic-mcp'))
await optimisticSend
await testFailedCreatedSessionIsRemoved()
await testFailedCreatedSessionWithMessagesIsRetained()
await testUnsafeCleanupDoesNotMasqueradeAsHome()
+533
View File
@@ -0,0 +1,533 @@
import assert from 'node:assert/strict'
import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createServer, type ServerResponse } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { query, type SDKMessage, type SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'
interface CapturedRequest {
index: number
url: string
headers: Record<string, string | string[] | undefined>
body: Record<string, unknown>
userTexts: string[]
latestText: string
completed: boolean
aborted: boolean
responseText?: string
}
interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
}
class AsyncQueue<T> implements AsyncIterable<T> {
private readonly values: T[] = []
private readonly waiters: Array<(result: IteratorResult<T>) => void> = []
private closed = false
push(value: T): void {
if (this.closed) throw new Error('input queue is closed')
const waiter = this.waiters.shift()
if (waiter) waiter({ value, done: false })
else this.values.push(value)
}
close(): void {
if (this.closed) return
this.closed = true
for (const waiter of this.waiters.splice(0)) waiter({ value: undefined, done: true })
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: () => {
const value = this.values.shift()
if (value !== undefined) return Promise.resolve({ value, done: false })
if (this.closed) return Promise.resolve({ value: undefined, done: true })
return new Promise<IteratorResult<T>>((resolve) => this.waiters.push(resolve))
}
}
}
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => { resolve = resolvePromise })
return { promise, resolve }
}
function sse(res: ServerResponse, event: string, data: unknown): void {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`)
}
function messageStart(res: ServerResponse, id: string): void {
sse(res, 'message_start', {
type: 'message_start',
message: {
id,
type: 'message',
role: 'assistant',
model: 'claude-append-probe',
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 8, output_tokens: 1 }
}
})
}
function textBlock(res: ServerResponse, text: string): void {
sse(res, 'content_block_start', {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' }
})
sse(res, 'content_block_delta', {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text }
})
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
}
function messageEnd(res: ServerResponse, stopReason = 'end_turn'): void {
sse(res, 'message_delta', {
type: 'message_delta',
delta: { stop_reason: stopReason, stop_sequence: null },
usage: { output_tokens: 4 }
})
sse(res, 'message_stop', { type: 'message_stop' })
res.end()
}
function plainText(res: ServerResponse, id: string, text: string): void {
messageStart(res, id)
textBlock(res, text)
messageEnd(res)
}
function contentTexts(body: Record<string, unknown>): string[] {
const messages = Array.isArray(body.messages) ? body.messages : []
const texts: string[] = []
for (const rawMessage of messages) {
if (!rawMessage || typeof rawMessage !== 'object') continue
const message = rawMessage as Record<string, unknown>
if (message.role !== 'user') continue
const content = message.content
if (typeof content === 'string') {
texts.push(content)
continue
}
if (!Array.isArray(content)) continue
for (const block of content) {
if (!block || typeof block !== 'object') continue
const item = block as Record<string, unknown>
if (item.type === 'text' && typeof item.text === 'string') texts.push(item.text)
}
}
return texts
}
function requestTrace(body: Record<string, unknown>): Array<Record<string, unknown>> {
const messages = Array.isArray(body.messages) ? body.messages : []
return messages.flatMap((rawMessage) => {
if (!rawMessage || typeof rawMessage !== 'object') return []
const message = rawMessage as Record<string, unknown>
const content = message.content
const parts = typeof content === 'string'
? [{ type: 'text', text: content }]
: Array.isArray(content)
? content.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const item = block as Record<string, unknown>
return item.type === 'text' && typeof item.text === 'string'
? [{ type: 'text', text: item.text }]
: [{ type: String(item.type) }]
})
: []
const text = parts.flatMap((part) => 'text' in part ? [part.text] : []).join('')
if (!text.includes('PROBE_') && !text.includes('answer-')) return []
return [{ role: message.role, content: parts }]
})
}
function compactFrame(message: SDKMessage): Record<string, unknown> {
const value = message as unknown as Record<string, unknown>
const inner = value.message && typeof value.message === 'object'
? value.message as Record<string, unknown>
: undefined
return {
type: value.type,
subtype: value.subtype,
uuid: value.uuid,
prompt_id: value.prompt_id,
user_message_uuid: value.user_message_uuid,
user_message_uuids: value.user_message_uuids,
result_index: value.result_index,
queued_turn_count: value.queued_turn_count,
is_error: value.is_error,
result: value.result,
parent_tool_use_id: value.parent_tool_use_id,
message_id: inner?.id,
message_role: inner?.role,
message_stop_reason: inner?.stop_reason,
content_types: Array.isArray(inner?.content)
? inner.content.map((block) => block && typeof block === 'object'
? (block as Record<string, unknown>).type
: typeof block)
: undefined
}
}
function inputMessage(sessionId: string, uuid: string, text: string): SDKUserMessage {
return {
type: 'user',
message: { role: 'user', content: [{ type: 'text', text }] },
parent_tool_use_id: null,
uuid: uuid as SDKUserMessage['uuid'],
session_id: sessionId
}
}
function executablePath(): string {
const executable = process.platform === 'win32' ? 'claude.exe' : 'claude'
const packageName = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}`
const candidates = [
join(process.cwd(), 'node_modules', ...packageName.split('/'), executable),
join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'vendor', executable)
]
const found = candidates.find((candidate) => existsSync(candidate))
if (!found) throw new Error(`Claude executable not found: ${candidates.join(', ')}`)
return found
}
function isolatedEnv(tempRoot: string, baseUrl: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...process.env,
HOME: join(tempRoot, 'home'),
USERPROFILE: join(tempRoot, 'home'),
CLAUDE_CONFIG_DIR: join(tempRoot, 'claude-config'),
CLAUDE_SECURESTORAGE_CONFIG_DIR: join(tempRoot, 'claude-config'),
ANTHROPIC_BASE_URL: baseUrl,
ANTHROPIC_API_KEY: 'ycode-loopback-probe-key',
ANTHROPIC_MODEL: 'claude-append-probe',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-append-probe',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-append-probe',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-append-probe',
ANTHROPIC_SMALL_FAST_MODEL: 'claude-append-probe',
CLAUDE_CODE_MAX_CONTEXT_TOKENS: '200000',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
DISABLE_TELEMETRY: '1'
}
for (const name of [
'ANTHROPIC_AUTH_TOKEN',
'CLAUDE_CODE_OAUTH_TOKEN',
'CLAUDE_CODE_USE_BEDROCK',
'CLAUDE_CODE_USE_VERTEX',
'CLAUDE_CODE_USE_FOUNDRY',
'CLAUDE_CODE_USE_ANTHROPIC_AWS',
'CLAUDE_CODE_USE_ANTHROPIC_GOOGLE_CLOUD',
'CLAUDE_CODE_USE_MANTLE',
'CLAUDE_CODE_USE_GATEWAY'
]) delete env[name]
return env
}
async function waitFor<T>(description: string, predicate: () => T | undefined | false, timeoutMs = 20_000): Promise<T> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
const value = predicate()
if (value) return value
await new Promise((resolve) => setTimeout(resolve, 20))
}
throw new Error(`timed out waiting for ${description}`)
}
const sdkPackage = JSON.parse(await readFile(
join(process.cwd(), 'node_modules', '@anthropic-ai', 'claude-agent-sdk', 'package.json'),
'utf8'
)) as { version?: string; claudeCodeVersion?: string }
const tempRoot = await mkdtemp(join(tmpdir(), 'ycode-claude-append-probe-'))
const workspace = join(tempRoot, 'workspace')
const outputPath = join(process.cwd(), '.tmp', 'claude-append-protocol-probe.json')
await mkdir(workspace, { recursive: true })
await mkdir(join(tempRoot, 'home'), { recursive: true })
await mkdir(join(tempRoot, 'claude-config'), { recursive: true })
await mkdir(join(process.cwd(), '.tmp'), { recursive: true })
await writeFile(join(workspace, 'README.md'), 'append protocol probe\n', 'utf8')
const requests: CapturedRequest[] = []
const runtimeMessages: SDKMessage[] = []
const stderr: string[] = []
const firstResponseGate = deferred<void>()
const processAbort = new AbortController()
const server = createServer((req, res) => {
let raw = ''
req.on('data', (chunk) => { raw += chunk })
req.on('end', async () => {
let body: Record<string, unknown> = {}
try { body = JSON.parse(raw || '{}') as Record<string, unknown> } catch { body = { raw } }
if (!req.url?.includes('/messages')) {
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{}')
return
}
const userTexts = contentTexts(body)
const captured: CapturedRequest = {
index: requests.length + 1,
url: req.url ?? '',
headers: req.headers,
body,
userTexts,
latestText: userTexts.at(-1) ?? '',
completed: false,
aborted: false
}
requests.push(captured)
res.on('close', () => {
if (!captured.completed) captured.aborted = true
})
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive'
})
const responseText = `answer-${captured.index}:${captured.userTexts.join('|')}`
captured.responseText = responseText
if (captured.index === 1) {
await firstResponseGate.promise
}
if (!res.destroyed) {
captured.completed = true
plainText(res, `msg_append_${captured.index}`, responseText)
}
})
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
if (!address || typeof address === 'string') throw new Error('loopback model did not bind')
const baseUrl = `http://127.0.0.1:${address.port}`
const sessionId = randomUUID()
const uuids = {
initial: randomUUID(),
appendA: randomUUID(),
appendB: randomUUID()
}
const input = new AsyncQueue<SDKUserMessage>()
let instance: ReturnType<typeof query> | null = null
let pumpError: unknown
let pump = Promise.resolve()
let appendsQueuedBeforeFirstResponse = false
try {
instance = query({
prompt: input,
options: {
cwd: workspace,
model: 'claude-append-probe',
pathToClaudeCodeExecutable: executablePath(),
env: isolatedEnv(tempRoot, baseUrl),
includePartialMessages: true,
abortController: processAbort,
permissionMode: 'default',
settingSources: [],
strictMcpConfig: true,
tools: [],
allowedTools: [],
plugins: [],
agents: {},
skills: [],
systemPrompt: { type: 'preset', preset: 'claude_code' },
sessionId,
maxTurns: 4,
stderr: (data) => {
stderr.push(data)
if (process.env.DEBUG_CLAUDE_APPEND_PROBE === '1') process.stderr.write(`[claude-probe] ${data}`)
}
}
})
const currentInstance = instance
pump = (async () => {
try {
for await (const message of currentInstance) runtimeMessages.push(message)
} catch (error) {
pumpError = error
}
})()
input.push(inputMessage(sessionId, uuids.initial, 'PROBE_INITIAL'))
await waitFor('first model request', () => requests[0])
// Hold request 1 so both appends are submitted while the turn is active.
input.push(inputMessage(sessionId, uuids.appendA, 'PROBE_APPEND_A'))
input.push(inputMessage(sessionId, uuids.appendB, 'PROBE_APPEND_B'))
appendsQueuedBeforeFirstResponse = true
await new Promise((resolve) => setTimeout(resolve, 250))
firstResponseGate.resolve()
await waitFor('all queued turns to settle', () => {
const results = runtimeMessages.filter((message) => message.type === 'result')
const last = results.at(-1) as unknown as Record<string, unknown> | undefined
return results.length >= 2 && last?.queued_turn_count === 0
}, 30_000)
await new Promise((resolve) => setTimeout(resolve, 300))
const results = runtimeMessages
.filter((message) => message.type === 'result')
.map((message) => message as unknown as Record<string, unknown>)
const assistants = runtimeMessages
.filter((message) => message.type === 'assistant')
.map((message) => message as unknown as Record<string, unknown>)
const userFrames = runtimeMessages
.filter((message) => message.type === 'user')
.map((message) => message as unknown as Record<string, unknown>)
const firstResult = results[0]
const finalResult = results.at(-1)
const firstResultUuids = Array.isArray(firstResult?.user_message_uuids)
? firstResult.user_message_uuids as string[]
: []
const finalResultUuids = Array.isArray(finalResult?.user_message_uuids)
? finalResult.user_message_uuids as string[]
: []
assert.equal(requests[0]?.aborted, false, 'the first model request must complete without cancellation')
assert.ok(requests.length >= 2, 'queued appends must produce another model request')
assert.ok(firstResult, 'the initial turn must emit a result')
assert.ok(finalResult, 'the queued append turn must emit a result')
assert.ok(firstResultUuids.includes(uuids.initial), 'the first result must be attributed to the initial UUID')
assert.ok(!firstResultUuids.includes(uuids.appendA), 'the first result must not claim append A')
assert.ok(!firstResultUuids.includes(uuids.appendB), 'the first result must not claim append B')
assert.ok(
finalResultUuids.includes(uuids.appendA) && finalResultUuids.includes(uuids.appendB),
'a later result must cover both queued appends'
)
assert.ok(results.length >= 2, 'one result must not stand for every queued send')
assert.equal(userFrames.length, 0, 'text sends must not be replayed as SDK user frames')
assert.equal(assistants.length, 2, 'each logical turn must emit one complete assistant frame')
const assistantUuidSets = assistants.map((assistant) => Array.isArray(assistant.user_message_uuids)
? assistant.user_message_uuids as string[]
: [])
assert.ok(!assistantUuidSets[0]?.includes(uuids.appendA), 'the first assistant must not claim append A')
assert.ok(
assistantUuidSets[1]?.includes(uuids.appendA) && assistantUuidSets[1]?.includes(uuids.appendB),
'the second assistant must cover both coalesced appends'
)
for (const [index, frame] of [...assistants, ...results].entries()) {
const frameUuids = Array.isArray(frame.user_message_uuids) ? frame.user_message_uuids as string[] : []
assert.equal(frameUuids.at(-1), frame.user_message_uuid, `frame ${index} must use the last member as user_message_uuid`)
}
assert.equal(pumpError, undefined, 'the SDK iterator must not fail during the probe')
const report = {
result: 'PASS',
sdkVersion: sdkPackage.version,
claudeCodeVersion: sdkPackage.claudeCodeVersion,
loopback: baseUrl,
activeAppend: {
appendsQueuedBeforeFirstResponse,
interruptCalled: false,
inputClosedBeforeResults: false
},
uuids,
requests: requests.map((request) => ({
index: request.index,
url: request.url,
completed: request.completed,
aborted: request.aborted,
latestText: request.latestText,
userTexts: request.userTexts
.filter((text) => text.includes('PROBE_') || text.includes('answer-'))
.slice(-8),
messages: requestTrace(request.body)
})),
results: results.map((result) => ({
subtype: result.subtype,
is_error: result.is_error,
result_index: result.result_index,
queued_turn_count: result.queued_turn_count,
user_message_uuid: result.user_message_uuid,
user_message_uuids: result.user_message_uuids,
prompt_id: result.prompt_id,
result: result.result
})),
assistantKeys: assistants.map((assistant) => Object.keys(assistant).sort()),
resultKeys: results.map((result) => Object.keys(result).sort()),
assistants: assistants.map((assistant) => ({
uuid: assistant.uuid,
user_message_uuid: assistant.user_message_uuid,
user_message_uuids: assistant.user_message_uuids,
prompt_id: assistant.prompt_id,
parent_tool_use_id: assistant.parent_tool_use_id,
message_id: (assistant.message as Record<string, unknown> | undefined)?.id
})),
userFrameCount: userFrames.length,
userFrames: userFrames.map((frame) => ({
uuid: frame.uuid,
user_message_uuid: frame.user_message_uuid,
user_message_uuids: frame.user_message_uuids,
prompt_id: frame.prompt_id,
message: frame.message
})),
promptIdObserved: runtimeMessages.some((message) => {
const value = message as unknown as Record<string, unknown>
return typeof value.prompt_id === 'string'
}),
assistantPromptIdObserved: assistants.some((assistant) => typeof assistant.prompt_id === 'string'),
frames: runtimeMessages.map(compactFrame),
stderr: stderr.slice(-20)
}
await writeFile(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.log(JSON.stringify({
result: report.result,
outputPath,
uuids,
activeAppend: report.activeAppend,
requests: report.requests,
results: report.results,
assistants: report.assistants,
userFrameCount: report.userFrameCount,
userFrames: report.userFrames,
assistantKeys: report.assistantKeys,
resultKeys: report.resultKeys,
promptIdObserved: report.promptIdObserved,
assistantPromptIdObserved: report.assistantPromptIdObserved
}, null, 2))
} catch (error) {
const diagnostic = {
result: 'FAIL',
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
pumpError: pumpError instanceof Error ? `${pumpError.name}: ${pumpError.message}` : pumpError,
uuids,
requests: requests.map((request) => ({
index: request.index,
completed: request.completed,
aborted: request.aborted,
userTexts: request.userTexts
})),
frames: runtimeMessages.map(compactFrame),
stderr: stderr.slice(-40)
}
await writeFile(outputPath, `${JSON.stringify(diagnostic, null, 2)}\n`, 'utf8')
console.error(JSON.stringify(diagnostic, null, 2))
throw error
} finally {
input.close()
processAbort.abort()
instance?.close()
await Promise.race([
pump,
new Promise((resolve) => setTimeout(resolve, 2000))
])
await new Promise<void>((resolve) => server.close(() => resolve()))
await rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch(() => undefined)
}
+199
View File
@@ -0,0 +1,199 @@
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
const testRoot = await fs.mkdtemp(path.resolve('.tmp/claude-capabilities-'))
const userData = path.join(testRoot, 'data')
const workspace = path.join(testRoot, 'workspace')
await Promise.all([userData, workspace].map(folder => fs.mkdir(folder, { recursive: true })))
const checks: string[] = []
const check = (name: string, actual: unknown, expected: unknown): void => {
assert.deepEqual(actual, expected, name)
checks.push(name)
}
function document(name: string, extra = ''): string {
return `---\nname: ${name}\ndescription: Controlled ${name}\n${extra}---\nInstructions for ${name}. Read references/guide.txt when needed.\n`
}
async function skill(scope: '.agents/skills' | '.claude/skills', folder: string, name: string, extra = ''): Promise<string> {
const root = path.join(workspace, scope, folder)
await fs.mkdir(path.join(root, 'references'), { recursive: true })
await fs.writeFile(path.join(root, 'SKILL.md'), document(name, extra))
await fs.writeFile(path.join(root, 'references', 'guide.txt'), `RESOURCE_${name}`)
return root
}
await skill('.agents/skills', 'automatic', 'automatic')
await skill('.agents/skills', 'manual', 'manual', 'disable-model-invocation: true\n')
await skill('.agents/skills', 'selected-only', 'selected-only', 'user-invocable: false\n')
await skill('.agents/skills', 'disabled', 'disabled')
await skill('.claude/skills', 'legacy', 'legacy')
const electronStub = {
name: 'isolated-electron-path',
setup(context: import('esbuild').PluginBuild) {
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'capability-test' }))
context.onLoad({ filter: /.*/, namespace: 'capability-test' }, () => ({
loader: 'js',
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:()=>{throw Error('unused')},decryptString:()=>{throw Error('unused')}};`
}))
}
}
async function compile(entry: string, file: string): Promise<string> {
const outfile = path.join(testRoot, file)
await build({ entryPoints: [path.resolve(entry)], outfile, bundle: true, platform: 'node', format: 'esm',
packages: 'external', logLevel: 'warning', plugins: [electronStub] })
return outfile
}
const adapterPath = await compile('src/main/capabilities/claude-capabilities.ts', 'adapter.mjs')
const skillsPath = await compile('src/main/capabilities/skills.ts', 'skills.mjs')
const mcpPath = await compile('src/main/capabilities/mcp.ts', 'mcp.mjs')
const adapter = await import(pathToFileURL(adapterPath).href)
const skills = await import(pathToFileURL(skillsPath).href)
const listed = await skills.listSkills(workspace)
const byName = (name: string) => listed.find((item: { name: string }) => item.name === name)
const automatic = byName('automatic')
const manual = byName('manual')
const selectedOnly = byName('selected-only')
const disabled = byName('disabled')
assert.ok(automatic && manual && selectedOnly && disabled)
check('only .agents/skills is discovered', listed.some((item: { name: string }) => item.name === 'legacy'), false)
await skills.setSkillEnabled(disabled.id, false, workspace)
let executeSignal: AbortSignal | undefined
let discoverOptions: { serverId?: string; query?: string } | undefined
const mcp = {
async discover(options?: { serverId?: string; query?: string }) {
discoverOptions = options
return [
{ id: 'ready', name: 'Ready server', status: 'connected', available: true,
tools: [
{ name: 'echo', description: 'Echo text', inputSchema: { type: 'object', properties: { text: { type: 'string' } } } },
{ name: 'search-only', description: 'Find needles', inputSchema: { type: 'object', properties: {} } }
] },
{ id: 'untrusted', name: 'Unapproved server', status: 'disconnected', available: false,
reason: '请先批准当前 MCP 配置', tools: [] }
]
},
async execute(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal) {
executeSignal = signal
if (serverId === 'untrusted') return { ok: false, content: '请先批准当前 MCP 配置' }
if (serverId === 'disabled') return { ok: false, content: 'MCP 服务已禁用' }
if (toolName === 'wait') return new Promise<never>((_resolve, reject) => {
signal.addEventListener('abort', () => reject(Object.assign(new Error('MCP 调用已取消'), { name: 'AbortError' })), { once: true })
})
return { ok: true, content: `${serverId}:${String(args.text ?? '')}` }
}
}
const runtime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [manual.id], selectedMcpServerIds: ['ready'] }, { mcp })
const rootController = new AbortController()
const rootBoundRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace, requestSignal: () => rootController.signal }, { mcp })
const rootBoundCall = rootBoundRuntime.callMcp('ready', 'wait', {}, new AbortController().signal)
await new Promise(resolve => setTimeout(resolve, 0))
rootController.abort()
await assert.rejects(rootBoundCall, /取消/)
check('root cancellation reaches MCP independently of SDK callback signal', executeSignal?.aborted, true)
const catalog = await runtime.list()
check('automatic skill appears without selection', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === automatic.id && !item.selected), true)
check('explicit manual-only skill appears as selected', catalog.skills.some((item: { id: string; selected: boolean }) => item.id === manual.id && item.selected), true)
check('user-invocable=false remains model-discoverable but unselected',
catalog.skills.some((item: { id: string; selected: boolean }) => item.id === selectedOnly.id && !item.selected), true)
check('disabled skill is omitted', catalog.skills.some((item: { id: string }) => item.id === disabled.id), false)
check('MCP selection is message-scoped metadata', [catalog.mcp.find((item: { id: string }) => item.id === 'ready').selected,
catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').selected], [true, false])
check('approval failure is reported truthfully', catalog.mcp.find((item: { id: string }) => item.id === 'untrusted').reason, '请先批准当前 MCP 配置')
check('default discovery omits tool schemas',
'inputSchema' in catalog.mcp.find((item: { id: string }) => item.id === 'ready').tools[0], false)
check('default discovery sends summary-only options', discoverOptions, {})
const toolSearch = await runtime.list('Echo')
check('tool-only search keeps its parent server', toolSearch.mcp.map((item: { id: string }) => item.id), ['ready'])
check('tool search returns only the matched tool with schema',
toolSearch.mcp[0].tools, [{ name: 'echo', description: 'Echo text',
inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }])
check('tool search forwards query to MCP discovery', discoverOptions, { query: 'Echo' })
const drilled = await runtime.list(undefined, 'ready')
check('server drill-down returns all tool schemas',
drilled.mcp[0].tools.every((item: { inputSchema?: unknown }) => item.inputSchema !== undefined), true)
check('server drill-down forwards server identity', discoverOptions, { serverId: 'ready' })
check('automatic skill body loads on demand', (await runtime.loadSkill(automatic.id)).includes('Instructions for automatic.'), true)
check('explicit selected skill body loads', (await runtime.explicitSkillContext()).includes('Instructions for manual.'), true)
check('relative skill resource resolves from package root',
await runtime.skillResource(automatic.id, 'references/guide.txt'), 'RESOURCE_automatic')
await assert.rejects(runtime.skillResource(automatic.id, '../outside.txt'), /路径/)
checks.push('resource traversal is rejected')
check('automatic-only skill remains available to the model',
(await runtime.loadSkill(selectedOnly.id)).includes('Instructions for selected-only.'), true)
check('automatic-only resource remains model-readable',
await runtime.skillResource(selectedOnly.id, 'references/guide.txt'), 'RESOURCE_selected-only')
const automaticRuntime = adapter.createClaudeCapabilityRuntime({ cwd: workspace }, { mcp })
await assert.rejects(automaticRuntime.loadSkill(manual.id), /自动调用/)
checks.push('manual-only skill remains unavailable without an explicit reference')
await assert.rejects(automaticRuntime.loadSkill(disabled.id), /禁用/)
checks.push('disabled skill body cannot load')
const explicitOnly = adapter.createClaudeCapabilityRuntime({ cwd: workspace, explicitSkillIds: [selectedOnly.id] }, { mcp })
await assert.rejects(explicitOnly.loadSkill(selectedOnly.id), /手动调用/)
checks.push('explicit selection cannot bypass user-invocable=false')
runtime.setRequestSelection({ explicitSkillIds: [], selectedMcpServerIds: [] })
const nextCatalog = await runtime.list()
check('next request clears prior explicit skill and MCP selection',
[nextCatalog.skills.some((item: { id: string }) => item.id === manual.id),
nextCatalog.mcp.some((item: { id: string; selected: boolean }) => item.id === 'ready' && item.selected)], [false, false])
await assert.rejects(runtime.loadSkill(manual.id), /自动调用/)
checks.push('explicit skill permission does not leak into the next request')
check('real MCP call is passed through', await runtime.callMcp('ready', 'echo', { text: 'actual' }, new AbortController().signal),
{ ok: true, content: 'ready:actual' })
check('unapproved MCP call fails instead of pretending success',
await runtime.callMcp('untrusted', 'echo', {}, new AbortController().signal),
{ ok: false, content: '请先批准当前 MCP 配置' })
check('disabled MCP call fails',
await runtime.callMcp('disabled', 'echo', {}, new AbortController().signal),
{ ok: false, content: 'MCP 服务已禁用' })
const controller = new AbortController()
const pending = runtime.callMcp('ready', 'wait', {}, controller.signal)
await new Promise<void>(resolve => setImmediate(resolve))
controller.abort()
await assert.rejects(pending, (error: unknown) => (error as Error).name === 'AbortError')
check('cancellation signal reaches MCP execution', executeSignal?.aborted, true)
await skills.setSkillEnabled(disabled.id, false, workspace)
await assert.rejects(runtime.loadSkill(disabled.id), /禁用/)
checks.push('global disable takes effect on the next bridge call')
await assert.rejects(runtime.skillResource(disabled.id, 'references/guide.txt'), /禁用/)
checks.push('disabled skill resource cannot load')
const realMcp = await import(pathToFileURL(mcpPath).href)
const mcpEvents = path.join(testRoot, 'mcp-events.jsonl')
try {
let fixture = await realMcp.saveMcpServer({
name: 'Schema fixture',
transport: 'stdio',
command: process.execPath,
args: [path.resolve('scripts/test-mcp-server.mjs'), 'stdio', mcpEvents],
enabled: true
})
await realMcp.trustMcpServer(fixture.id, fixture.configHash)
fixture = await realMcp.connectMcpServer(fixture.id)
const summary = await realMcp.discoverMcpCapabilities()
check('real MCP default discovery omits schemas',
summary.find((item: { id: string }) => item.id === fixture.id).tools.every((item: object) => !('inputSchema' in item)), true)
const drilled = await realMcp.discoverMcpCapabilities({ serverId: fixture.id })
const echo = drilled[0].tools.find((item: { name: string }) => item.name === 'echo')
check('real MCP server drill-down includes schemas',
echo.inputSchema, { type: 'object', properties: { text: { type: 'string' } } })
const searched = await realMcp.discoverMcpCapabilities({ query: 'echo' })
check('real MCP tool search includes schema',
searched.find((item: { id: string }) => item.id === fixture.id).tools,
[{ name: 'echo', description: 'Controlled stdio echo',
inputSchema: { type: 'object', properties: { text: { type: 'string' } } } }])
} finally {
await realMcp.disposeMcpServers()
}
assert.ok(runtime.server, 'SDK MCP server should be constructed')
checks.push('Claude SDK capability server constructed')
await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({ result: 'PASS', checks }, null, 2))
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot }))
+81 -1
View File
@@ -121,6 +121,26 @@ const server = createServer((req, res) => {
connection: 'keep-alive'
})
const id = `msg_kernel_${requests.length}`
if (latestContent.includes('APPEND_ACTIVE_SMOKE')) {
setTimeout(() => { if (!res.destroyed) plainText(res, id, 'Initial append turn.') }, 400)
return
}
if (userText.includes('APPEND_GUIDE_A') || userText.includes('APPEND_GUIDE_B')) {
plainText(res, id, 'Appended answer.')
return
}
if (latestContent.includes('HOLD_SMOKE')) {
setTimeout(() => { if (!res.destroyed) plainText(res, id, 'Held response.') }, 500)
return
}
if (latestContent.includes('AFTER_CANCEL_SMOKE')) {
plainText(res, id, 'After cancellation.')
return
}
if (userText.includes('AFTER_COMPACT_SMOKE')) {
plainText(res, id, 'After compaction.')
return
}
if (userText.includes('Write the title in the predominant language')) {
plainText(res, id, 'Plan Smoke')
return
@@ -300,11 +320,71 @@ try {
assert.ok(resumedRequest)
assert.ok(resumedRequest.userText.includes('ASK_SMOKE'))
assert.ok(resumedRequest.userText.includes('PLAN_SMOKE'))
const beforeSummary = requests.length
const summary = await resumed.getContextSummary()
assert.ok(summary && summary.categories.length > 0, 'native summary must return actual structured categories')
assert.equal(requests.length, beforeSummary, 'summary must not call the model or per-category count API')
const controller = new AbortController()
const held = resumed.runTurn(request('HOLD_SMOKE', randomUUID(), controller))
const rejection = assert.rejects(held, /取消/)
for (let index = 0; index < 500 && !requests.some(item => item.userText.includes('HOLD_SMOKE')); index++) {
await new Promise(resolve => setTimeout(resolve, 10))
}
assert.ok(requests.some(item => item.userText.includes('HOLD_SMOKE')))
controller.abort()
await rejection
const afterCancel = await resumed.runTurn(request('AFTER_CANCEL_SMOKE', randomUUID(), new AbortController()))
assert.equal(afterCancel.content, 'After cancellation.', 'cancelled producer must not settle the next run')
const groups: string[][] = []
const segmentResults: string[] = []
const appendController = new AbortController()
const appendRun = resumed.runTurn({ ...request('APPEND_ACTIVE_SMOKE', randomUUID(), appendController),
onSegmentComplete: async result => { segmentResults.push(result.content) },
onSegmentStart: async inputs => { groups.push(inputs.map(input => input.userMessageId)) }
})
void appendRun.catch(() => {})
for (let index = 0; index < 500 && !requests.some(item => JSON.stringify(item.body.messages ?? []).includes('APPEND_ACTIVE_SMOKE')); index++) await new Promise(resolve => setTimeout(resolve, 10))
assert.ok(requests.some(item => JSON.stringify(item.body.messages ?? []).includes('APPEND_ACTIVE_SMOKE')))
const guideA = randomUUID(), guideB = randomUUID()
let acknowledged = false
const appended = Promise.all([
resumed.appendInput({ userMessageId: guideA, prompt: 'APPEND_GUIDE_A' }),
resumed.appendInput({ userMessageId: guideB, prompt: 'APPEND_GUIDE_B' })
]).then(() => { acknowledged = true })
assert.equal(acknowledged, false, 'pushing into the input stream is not delivery acknowledgement')
const appendResult = await appendRun
await appended
assert.equal(appendResult.content, 'Appended answer.')
assert.deepEqual(segmentResults, ['Initial append turn.'], 'first result must not settle appended inputs')
assert.deepEqual(groups.flat(), [guideA, guideB], 'native merged UUIDs must be associated exactly once')
assert.equal(appendController.signal.aborted, false, 'guidance must not cancel the active run')
await assert.rejects(resumed.appendInput({ userMessageId: randomUUID(), prompt: 'idle' }), error => (error as { submitted?: boolean }).submitted === false)
if (process.env.CLAUDE_PROTOCOL_PROBE === '1') {
const observed: Array<Record<string, unknown>> = []
const target = resumed as unknown as { handleMessage(message: unknown): Promise<void> }
const original = target.handleMessage.bind(target)
target.handleMessage = message => { observed.push(message as Record<string, unknown>); return original(message) }
const compact = await resumed.runTurn({ ...request('/compact', randomUUID(), new AbortController()), command: 'compact' })
console.log(JSON.stringify({ probe: 'compact', status: compact.status,
frames: observed.map(frame => ({ type: frame.type, subtype: frame.subtype, promptId: frame.prompt_id, ids: frame.user_message_uuids })),
boundary: observed.some(frame => frame.subtype === 'compact_boundary') }))
assert.ok(observed.some(frame => frame.subtype === 'compact_boundary'), 'native compaction must emit its boundary')
assert.equal(compact.content, '上下文压缩完成')
const postCompact = await resumed.runTurn(request('AFTER_COMPACT_SMOKE', randomUUID(), new AbortController()))
if (postCompact.content !== 'After compaction.') console.error(JSON.stringify({ postCompact, requests: requests.slice(-3).map(item => ({ url: item.url, userText: item.userText.slice(-1200) })), frames: observed.map(frame => ({ type: frame.type, subtype: frame.subtype, ids: frame.user_message_uuids })) }))
assert.equal(postCompact.content, 'After compaction.')
const postRequest = requests.findLast(item => item.userText.includes('AFTER_COMPACT_SMOKE'))
assert.ok(postRequest)
assert.equal(postRequest.userText.includes('ASK_SMOKE'), false, 'native compaction must replace old full inputs on the next upstream request')
}
console.log(JSON.stringify({
result: 'PASS',
requests: requests.length,
events: events.length,
turns: 3,
turns: process.env.CLAUDE_PROTOCOL_PROBE === '1' ? 9 : 7,
basePath: requests.find((item) => item.url.includes('/v1/messages'))?.url
}))
} finally {
+62 -1
View File
@@ -6,6 +6,8 @@ import { buildComposerSendPayload, MAX_ATTACHMENT_BYTES, oversizedAttachmentName
import { MessageActionToolbar } from '../src/renderer/src/workbuddy/message-actions/MessageActionToolbar'
import { UserMessageToolbar } from '../src/renderer/src/workbuddy/message-actions/UserMessageToolbar'
import { UserMessageEditor } from '../src/renderer/src/workbuddy/conversation/UserMessageEditor'
import { messageCapabilityRefs } from '../src/renderer/src/workbuddy/conversation/message-capabilities'
import { draftFingerprint, draftIsEmpty, draftPlainText, mergeFailedDraft } from '../src/renderer/src/workbuddy/composer/draft-fingerprint'
import { buildCopyMessagePayload, buildUserMessageClipboard, getSortedRequestMessages, projectSafeMessage, safeStringify, USER_CONTENT_BLOCKS_MIME } from '../src/renderer/src/workbuddy/message-actions/project-safe-message'
import type { ChatMessage } from '../src/shared/types'
@@ -41,6 +43,61 @@ const legacy: ChatMessage = {
createdAt: 1
}
const userMessage: ChatMessage = { id: 'user-copy', role: 'user', content: 'plain user text', createdAt: 1 }
assert.deepEqual(messageCapabilityRefs({ ...userMessage, skills: [{ id: 'skill-a', name: 'Skill A', uri: '/skill-a' }],
mcpServerIds: ['mcp-a'], command: 'compact' }), { skillIds: ['skill-a'], mcpServerIds: ['mcp-a'], command: 'compact' },
'editing and regenerating must preserve message-scoped capability references')
const compactDraft = { text: '', command: 'compact' as const }
assert.notEqual(draftFingerprint(compactDraft), draftFingerprint({ text: '/compact', command: 'compact' }),
'compact draft identity must use the real empty editor snapshot, not the synthetic wire text')
assert.equal(draftIsEmpty({ text: '', userContent: [{ type: 'resource', id: 'C:/work/a.ts' }] }), false,
'rich-only drafts are not empty')
assert.equal(draftIsEmpty({ text: '', userContent: [{ type: 'text', text: ' ' }] }), true,
'empty rich text does not keep a draft alive')
assert.equal(draftPlainText({ text: '', userContent: [{ type: 'text', text: 'rich-only text' }] }), 'rich-only text',
'rich-only textual content reaches the send payload')
const failedDraft = {
text: 'old request',
attachments: [{ id: 'attachment-a', name: 'a.txt', mimeType: 'text/plain', size: 1 }],
resources: ['C:/work/a.ts', 'C:/work/shared.ts'],
quotes: [{ id: 'quote-a', kind: 'table' as const, sourceId: 'source-a', markdown: 'a', preview: 'a' }],
skills: [{ id: 'skill-a', name: 'Skill A' }],
mcpServerIds: ['mcp-a']
}
const currentDraft = {
text: 'new request',
attachments: [
{ id: 'attachment-b', name: 'b.txt', mimeType: 'text/plain', size: 1 },
{ id: 'attachment-a', name: 'duplicate.txt', mimeType: 'text/plain', size: 1 }
],
resources: ['C:/work/b.ts', 'C:/work/shared.ts'],
quotes: [{ id: 'quote-b', kind: 'table' as const, sourceId: 'source-b', markdown: 'b', preview: 'b' }],
skills: [{ id: 'skill-b', name: 'Skill B' }, { id: 'skill-a', name: 'Duplicate' }],
mcpServerIds: ['mcp-b', 'mcp-a']
}
const mergedDraft = mergeFailedDraft(currentDraft, failedDraft)
assert.equal(mergedDraft.draft.text, 'old request\nnew request', 'failed content must stay before subsequent input')
assert.deepEqual(mergedDraft.draft.attachments?.map(item => item.id), ['attachment-a', 'attachment-b'])
assert.deepEqual(mergedDraft.draft.resources, ['C:/work/a.ts', 'C:/work/shared.ts', 'C:/work/b.ts'])
assert.deepEqual(mergedDraft.draft.quotes?.map(item => item.id), ['quote-a', 'quote-b'])
assert.deepEqual(mergedDraft.draft.skills?.map(item => item.id), ['skill-a', 'skill-b'])
assert.deepEqual(mergedDraft.draft.mcpServerIds, ['mcp-a', 'mcp-b'])
assert.equal(mergedDraft.commandConflict, false)
const orderedFailure = mergeFailedDraft({ text: 'later' }, {
text: 'beforeafter', resources: ['C:/work/inline.ts'],
userContent: [{ type: 'text', text: 'before' }, { type: 'resource', id: 'C:/work/inline.ts' }, { type: 'text', text: 'after' }]
})
assert.deepEqual(orderedFailure.draft.userContent, [
{ type: 'text', text: 'before' }, { type: 'resource', id: 'C:/work/inline.ts' }, { type: 'text', text: 'after\nlater' }
], 'failed draft keeps inline reference order before subsequent input')
const commandFailure = mergeFailedDraft({ text: 'next ordinary draft' }, { text: '', command: 'compact' })
assert.equal(commandFailure.draft.command, 'compact', 'failed command tag remains removable instead of being discarded')
assert.equal(commandFailure.commandConflict, true, 'mixed command and ordinary content must be surfaced as a conflict')
assert.equal(mergeFailedDraft({ text: 'new' }, { text: '', userContent: [{ type: 'text', text: 'old rich text' }] }).draft.text,
'old rich text\nnew', 'failed rich-only text is not lost when subsequent input exists')
const failedQueueEdit = mergeFailedDraft({ text: 'typed after submit' }, { text: 'queued edit', queueEditId: 'queue-item-a' })
assert.equal(failedQueueEdit.draft.queueEditId, 'queue-item-a', 'failed queue-edit submissions retain the durable handoff identity')
assert.equal(draftFingerprint({ text: 'queued edit', queueEditId: 'queue-item-a' }), draftFingerprint({ text: 'queued edit' }),
'queue handoff identity never changes the submitted-content fingerprint')
const userClipboard = buildUserMessageClipboard({ ...userMessage, content: '<plain>&\u200B' })
assert.equal(userClipboard.plainText, '<plain>&', 'user text/plain omits the source zero-width marker')
assert.equal(userClipboard.html, '&lt;plain&gt;&amp;\u200B')
@@ -175,7 +232,11 @@ assert.equal(composerMarkup.indexOf('Model slot') < composerMarkup.indexOf('语
assert.ok(composerMarkup.indexOf('cr-input-toolbar__left') < composerMarkup.indexOf('cr-input-toolbar__right'))
assert.ok(composerMarkup.indexOf('cr-input-toolbar__right') < composerMarkup.indexOf('wb-composer__workspace-footer'), 'workspace footer remains outside the input toolbar')
const activeRunMarkup = renderToStaticMarkup(<ComposerControls value="next draft" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
assert.match(activeRunMarkup, /aria-label="停止"/)
assert.match(activeRunMarkup, /aria-label="停止当前运行"/)
assert.match(activeRunMarkup, /aria-label="发送"/)
const emptyActiveRunMarkup = renderToStaticMarkup(<ComposerControls value="" onChange={() => undefined} onSend={() => undefined} onStop={() => undefined} />)
assert.match(emptyActiveRunMarkup, /aria-label="停止"/)
assert.doesNotMatch(emptyActiveRunMarkup, /aria-label="发送"/)
assert.doesNotMatch(activeRunMarkup, /<textarea[^>]*disabled/)
console.log('Composer/message action regression cases passed')
+73
View File
@@ -0,0 +1,73 @@
import assert from 'node:assert/strict'
import { createContextUsageService, type ContextUsageDependencies } from '../src/main/context-usage-service'
import type { ChatMessage, ContextUsageBreakdown } from '../src/shared/types'
const sample = {
usage: { inputTokens: 1200, outputTokens: 100, totalTokens: 1300 },
contextWindow: 10_000,
source: 'upstream' as const,
sampledAt: 100,
requestId: 'request-a',
modelId: 'model-a'
}
const assistant = (overrides: Partial<ChatMessage> = {}): ChatMessage => ({
id: 'assistant-a',
role: 'assistant',
content: 'answer',
modelId: 'model-a',
requestId: 'request-a',
contextUsage: sample,
createdAt: 1,
status: 'done',
...overrides
})
const breakdown: ContextUsageBreakdown = {
requestId: 'request-a',
sequence: 3,
sampledAt: 101,
modelId: 'model-a',
contextWindow: 10_000,
method: 'native-summary',
scope: 'provider-request',
counts: { system: 100, tools: 100, conversation: 900, mcp: 50, skills: 50, other: 0 },
inputTokens: 1200,
partial: false
}
function service(overrides: Partial<ContextUsageDependencies> = {}) {
const dependencies: ContextUsageDependencies = {
messages: async () => [assistant()],
modelId: async () => 'model-a',
running: () => false,
summary: async () => undefined,
persistIfCurrent: async () => undefined,
...overrides
}
return createContextUsageService(dependencies)
}
assert.deepEqual(await service({ messages: async () => [] }).get('empty'),
{ status: 'unavailable', reason: '发送消息后可查看用量' })
const latest = await service().get('latest')
assert.equal(latest.status, 'latest')
assert.equal(latest.sample?.usage.inputTokens, 1200)
const previous = await service({ modelId: async () => 'model-b' }).get('previous')
assert.equal(previous.status, 'previous')
assert.equal(previous.reason, '来自切换模型前的最近请求')
const running = await service({ modelId: async () => 'model-b', running: () => true }).get('running')
assert.equal(running.status, 'latest', 'the active run must not be relabelled when the next-send model changes')
const summary = await service({ summary: async () => breakdown }).get('summary')
assert.equal(summary.breakdown?.sequence, 3)
assert.equal(summary.status, 'latest', 'a matching native summary augments the current request without replacing its upstream total')
const mismatched = await service({ summary: async () => ({ ...breakdown, requestId: 'older-request' }) }).get('mismatch')
assert.equal(mismatched.breakdown, undefined, 'a late summary for another request must not attach to the latest assistant message')
const summaryFailure = await service({ summary: async () => { throw new Error('query failed') } }).get('failure')
assert.equal(summaryFailure.sample?.usage.inputTokens, 1200, 'summary failures retain the real streamed usage sample')
console.log('context-usage-regression: latest, previous, active-run, summary identity and failure cases passed')
+75
View File
@@ -0,0 +1,75 @@
import assert from 'node:assert/strict'
import { composeComposer, isActiveComposerInteraction, type ComposerCompositionInput } from '../src/renderer/src/workbuddy/composer/input-composition'
const idle: ComposerCompositionInput = {}
let result = composeComposer(idle)
assert.equal(result.input.mounted, true)
assert.equal(result.input.destination, 'send')
assert.equal(result.queue.visible, false)
assert.equal(result.queue.hiddenBy, 'empty')
assert.equal(result.toolbar.permissionPlacement, 'after-add')
assert.equal(result.toolbar.primaryAction, 'send')
assert.equal(result.escapeStopsGeneration, false)
result = composeComposer({ running: true, queueCount: 2, queueRevision: 4 })
assert.equal(result.input.destination, 'queue', 'running messages are queued instead of interrupting the turn')
assert.equal(result.queue.visible, true)
assert.equal(result.toolbar.primaryAction, 'stop', 'stop remains reachable while the queue is visible')
for (const kind of ['question', 'approval', 'plan', 'mcp'] as const) {
result = composeComposer({
interaction: { id: `${kind}-1`, kind, status: 'pending' },
notice: { kind: 'error', id: 'global-error' },
task: { id: 'task-1', active: true },
queueCount: 3,
queueRevision: 8,
running: true
})
assert.equal(result.interaction?.kind, kind)
assert.equal(result.input.blockedByInteraction, true)
assert.equal(result.input.destination, 'blocked')
assert.equal(result.input.preservesDraft, true, 'takeover never clears the draft')
assert.equal(result.queue.visible, false, 'takeover hides the queue')
assert.equal(result.queue.hiddenBy, 'interaction')
assert.equal(result.queue.count, 3, 'queue data remains available while hidden')
assert.equal(result.queue.preservesItems, true)
assert.equal(result.task?.id, 'task-1', 'task data remains available while hidden')
assert.equal(result.notice, null, 'interaction-local errors must not duplicate a global notice')
assert.equal(result.toolbar.primaryAction, 'stop', 'stop is not stolen by takeover')
assert.deepEqual(result.stack.beforeInput.map(panel => panel.kind), [kind])
assert.equal(result.stack.queue, null)
}
result = composeComposer({
interaction: { id: 'answered', kind: 'approval', status: 'answered' },
task: { active: true },
queueCount: 2
})
assert.equal(result.interaction, null, 'settled interactions no longer take over')
assert.equal(result.queue.visible, true)
assert.deepEqual(result.stack.beforeQueue.map(panel => panel.kind), ['task'])
assert.equal(result.stack.queue?.kind, 'queue')
result = composeComposer({ notice: { kind: 'quota', id: 'quota-1' }, task: { active: true } })
assert.deepEqual(result.stack.beforeInput.map(panel => panel.kind), ['quota'])
result = composeComposer({ notice: { kind: 'error', id: 'error-1' }, task: { id: 'task-2', active: true }, queueCount: 1 })
assert.deepEqual(result.stack.beforeInput.map(panel => panel.kind), ['error'])
assert.equal(result.stack.beforeQueue.length, 0)
assert.equal(result.task?.id, 'task-2', 'notice keeps task data while suppressing its placement')
assert.equal(result.queue.visible, false, 'notice suppresses accessory queue rendering')
assert.equal(result.queue.hiddenBy, 'notice')
assert.equal(result.stack.queue, null)
assert.equal(isActiveComposerInteraction({ id: 'q', kind: 'question', status: 'responding' }), true)
assert.equal(isActiveComposerInteraction({ id: 'q', kind: 'question', status: 'cancelled' }), false)
const clamped = composeComposer({ queueCount: Number.NaN, queueRevision: -3 })
assert.equal(clamped.queue.count, 0)
assert.equal(clamped.queue.revision, 0)
const firstLayout = composeComposer({ queueCount: 1, queueRevision: 1 }).layoutKey
const secondLayout = composeComposer({ queueCount: 1, queueRevision: 2 }).layoutKey
assert.notEqual(firstLayout, secondLayout, 'layout key changes when queue height may change')
console.log('input-composition-regression: precedence, takeover, queue preservation, task placement, toolbar and escape rules passed')
@@ -0,0 +1,415 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { hostname } from 'node:os'
import { join, resolve } from 'node:path'
import { createPromptQueue } from '../src/main/prompt-queue'
import { resolveQueuedRequestSettings } from '../src/main/agent/queued-request-settings'
import type { SendPayload } from '../src/shared/ipc'
import type { PromptQueueSnapshot } from '../src/shared/prompt-queue'
const workspace = resolve('.tmp')
await fs.mkdir(workspace, { recursive: true })
const root = await fs.mkdtemp(join(workspace, 'prompt-queue-adversarial-'))
const results: Array<{ name: string; pass: boolean; error?: string }> = []
const tick = (ms = 20) => new Promise(resolve => setTimeout(resolve, ms))
const payload = (sessionId: string, text: string, extra: Partial<SendPayload> = {}): SendPayload =>
({ sessionId, text, ...extra })
async function waitFor(check: () => boolean | Promise<boolean>, label: string): Promise<void> {
for (let attempt = 0; attempt < 150; attempt++) {
if (await check()) return
await tick(10)
}
throw new Error('Timeout: ' + label)
}
async function verify(name: string, run: () => Promise<void>): Promise<void> {
try {
await run()
results.push({ name, pass: true })
} catch (error) {
results.push({ name, pass: false, error: error instanceof Error ? error.message : String(error) })
}
}
function options(file: string, input: {
blocked?: (sessionId: string) => boolean
resolveDispatch?: (payload: SendPayload, id: string) => SendPayload | Promise<SendPayload>
deliver?: (payload: SendPayload, id: string) => Promise<{
completion: Promise<{ status: 'done' | 'cancelled' | 'error' }>
}>
changed?: (snapshot: PromptQueueSnapshot) => void
} = {}) {
return {
file,
validate: async () => undefined,
blocked: async sessionId => input.blocked?.(sessionId) ?? true,
...(input.resolveDispatch ? { resolveDispatch: input.resolveDispatch } : {}),
deliver: input.deliver ?? (async () => ({
completion: new Promise<{ status: 'done' }>(() => undefined)
})),
changed: input.changed ?? (() => undefined)
}
}
try {
await verify('parallel same-instance enqueues preserve exactly 19 items', async () => {
const file = join(root, 'cap.json')
const queue = createPromptQueue(options(file))
const outcomes = await Promise.allSettled(Array.from({ length: 25 }, (_, index) =>
queue.enqueue(payload('cap', String(index)))))
assert.equal(outcomes.filter(item => item.status === 'fulfilled').length, 19)
assert.equal(outcomes.filter(item => item.status === 'rejected').length, 6)
const snapshot = await queue.get('cap')
assert.equal(snapshot.items.length, 19)
assert.equal(new Set(snapshot.items.map(item => item.id)).size, 19)
})
await verify('duplicate take is serialized to one winner', async () => {
const file = join(root, 'take.json')
const queue = createPromptQueue(options(file))
await queue.enqueue(payload('take', 'only'))
const id = (await queue.get('take')).items[0].id
const outcomes = await Promise.allSettled([queue.take('take', id), queue.take('take', id)])
assert.equal(outcomes.filter(item => item.status === 'fulfilled').length, 1)
assert.equal(outcomes.filter(item => item.status === 'rejected').length, 1)
assert.equal((await queue.get('take')).items.length, 0)
})
await verify('delivering item is immutable and notify does not duplicate delivery', async () => {
const file = join(root, 'delivery-lock.json')
let releaseDelivery!: () => void
let releaseCompletion!: (value: { status: 'done' }) => void
const deliveryGate = new Promise<void>(resolve => { releaseDelivery = resolve })
const completion = new Promise<{ status: 'done' }>(resolve => { releaseCompletion = resolve })
const delivered: string[] = []
const queue = createPromptQueue(options(file, {
blocked: () => false,
deliver: async (_value, id) => {
delivered.push(id)
await deliveryGate
return { completion }
}
}))
await queue.enqueue(payload('delivery', 'first'))
await queue.enqueue(payload('delivery', 'second'))
await waitFor(() => delivered.length === 1, 'first delivery reserve')
const delivering = await queue.get('delivery')
assert.equal(delivering.items[0].status, 'delivering')
const observer = createPromptQueue(options(file, { blocked: () => true }))
assert.equal((await observer.get('delivery')).items[0].status, 'delivering',
'fresh reads in the same process must preserve an owned active delivery')
await assert.rejects(queue.clear('delivery'), /正在交付/)
const first = delivering.items[0].id
const second = delivering.items[1].id
await queue.remove('delivery', second)
await assert.rejects(queue.take('delivery', first), /交付/)
await assert.rejects(queue.reorder('delivery', [first]), /排序/)
queue.notify('delivery')
queue.notify('delivery')
queue.notify('delivery')
await tick(50)
assert.equal(delivered.length, 1)
releaseDelivery()
releaseCompletion({ status: 'done' })
await waitFor(async () => (await queue.get('delivery')).items.length === 0, 'delivery cleanup')
})
await verify('clear removes a non-delivering session queue and is idempotent', async () => {
const file = join(root, 'clear.json')
const queue = createPromptQueue(options(file))
await queue.enqueue(payload('clear', 'one'))
await queue.enqueue(payload('clear', 'two'))
const cleared = await queue.clear('clear')
assert.deepEqual(cleared.items, [])
assert.equal(cleared.paused, false)
assert.deepEqual((await createPromptQueue(options(file)).get('clear')).items, [])
assert.deepEqual((await queue.clear('clear')).items, [])
})
await verify('first enqueue after an empty queue was paused resumes a new run without unpausing existing work', async () => {
const file = join(root, 'empty-pause.json')
let blocked = true
let finish!: (value: { status: 'done' }) => void
const delivered: string[] = []
const queue = createPromptQueue(options(file, {
blocked: () => blocked,
deliver: async value => {
delivered.push(value.text)
return { completion: new Promise<{ status: 'done' }>(resolve => { finish = resolve }) }
}
}))
await queue.pause('lifecycle', '上一条运行已停止')
assert.equal((await queue.get('lifecycle')).paused, true)
blocked = false
await queue.enqueue(payload('lifecycle', 'new run'))
await waitFor(() => delivered.length === 1, 'new run first delivery')
const active = await queue.get('lifecycle')
assert.equal(active.paused, false, 'a new run must not inherit a pause left on an empty queue')
assert.equal(active.reason, undefined)
finish({ status: 'done' })
await waitFor(async () => (await queue.get('lifecycle')).items.length === 0, 'first delivery cleanup')
blocked = true
await queue.enqueue(payload('lifecycle', 'queued while busy'))
await queue.pause('lifecycle', '用户停止当前队列')
await queue.enqueue(payload('lifecycle', 'later item'))
const retained = await queue.get('lifecycle')
assert.equal(retained.paused, true, 'a non-empty queue must retain its pause')
assert.equal(retained.reason, '用户停止当前队列')
assert.deepEqual(retained.items.map(item => item.payload.text), ['queued while busy', 'later item'])
assert.deepEqual(delivered, ['new run'])
})
await verify('incoming payload is validated before host side effects and persistence', async () => {
const file = join(root, 'incoming-validation.json')
let hostCalls = 0
const queue = createPromptQueue({
...options(file),
validate: async () => { hostCalls++ }
})
await assert.rejects(
queue.enqueue({ sessionId: 'incoming', text: '/compact', command: 'compact' } as SendPayload),
/追加消息无效.*命令/
)
assert.equal(hostCalls, 0, 'invalid transport payload must not reach host validation')
await assert.rejects(fs.access(file), (error: NodeJS.ErrnoException) => error.code === 'ENOENT')
await assert.rejects(
queue.enqueue({
sessionId: 'incoming',
text: 'label',
skillIds: ['skill:a'],
capabilityLabels: { 'skill:a': { name: 'not-a-string' } }
} as unknown as SendPayload),
/追加消息无效.*能力标签/
)
assert.equal(hostCalls, 0, 'malformed display labels must not reach host validation')
await assert.rejects(fs.access(file), (error: NodeJS.ErrnoException) => error.code === 'ENOENT')
})
await verify('host-rebuilt capability labels are validated before persistence', async () => {
const file = join(root, 'capability-labels.json')
const queue = createPromptQueue({
...options(file),
validate: async value => {
value.capabilityLabels = { 'skill:alpha': 'Alpha skill', 'mcp:server': 'Server' }
}
})
await queue.enqueue(payload('labels', 'use labels', {
skillIds: ['alpha'],
mcpServerIds: ['server'],
capabilityLabels: { 'skill:alpha': 'untrusted renderer label' }
}))
const stored = (await queue.get('labels')).items[0].payload.capabilityLabels
assert.deepEqual(stored, { 'skill:alpha': 'Alpha skill', 'mcp:server': 'Server' })
assert.deepEqual(
(await createPromptQueue(options(file)).get('labels')).items[0].payload.capabilityLabels,
stored,
'validated labels must survive a fresh disk read'
)
})
await verify('two queue instances cannot overwrite one another', async () => {
const file = join(root, 'two-instances.json')
const a = createPromptQueue(options(file))
const b = createPromptQueue(options(file))
await Promise.all([a.get('shared'), b.get('shared')])
await Promise.all([
a.enqueue(payload('shared', 'from-a')),
b.enqueue(payload('shared', 'from-b'))
])
await tick(80)
const fresh = createPromptQueue(options(file))
const snapshot = await fresh.get('shared')
assert.deepEqual(snapshot.items.map(item => item.payload.text).sort(), ['from-a', 'from-b'])
})
await verify('corrupt queue cannot redirect or duplicate item identity', async () => {
const file = join(root, 'corrupt.json')
await fs.writeFile(file, JSON.stringify({
version: 1,
queues: [{
sessionId: 'owner',
revision: 1,
paused: false,
items: [
{ id: 'same', createdAt: 1, status: 'pending', payload: payload('other', 'redirected') },
{ id: 'same', createdAt: 2, status: 'pending', payload: payload('other', 'duplicate') }
]
}]
}))
const queue = createPromptQueue(options(file))
await assert.rejects(queue.get('owner'), /损坏|重复|会话/)
})
await verify('invalid revision, status and payload permission are rejected', async () => {
const base = {
version: 1,
queues: [{
sessionId: 'invalid', revision: 1, paused: false,
items: [{ id: 'valid-id', createdAt: 1, status: 'pending', payload: payload('invalid', 'text') }]
}]
}
for (const [name, mutate] of [
['revision', (value: any) => { value.queues[0].revision = -1 }],
['status', (value: any) => { value.queues[0].items[0].status = 'mystery' }],
['permission', (value: any) => { value.queues[0].items[0].payload.permissionMode = 'admin' }],
['capability-label', (value: any) => { value.queues[0].items[0].payload.capabilityLabels = { 'skill:a': { name: 'bad' } } }]
] as const) {
const file = join(root, `invalid-${name}.json`)
const value = structuredClone(base)
mutate(value)
await fs.writeFile(file, JSON.stringify(value))
await assert.rejects(createPromptQueue(options(file)).get('invalid'), /损坏/)
}
})
await verify('post-delivery removal failure becomes paused unknown instead of stuck delivering', async () => {
const file = join(root, 'removal-failure.json')
let sabotage: Promise<void> | undefined
const queue = createPromptQueue(options(file, {
blocked: sessionId => sessionId !== 'removal',
changed: snapshot => {
if (!sabotage && snapshot.sessionId === 'removal' && snapshot.items[0]?.status === 'delivering') {
sabotage = (async () => {
await fs.rm(file, { force: true })
await fs.mkdir(file)
})()
}
},
deliver: async () => {
await sabotage
return { completion: Promise.resolve({ status: 'done' }) }
}
}))
await queue.enqueue(payload('bystander', 'must survive fallback'))
await queue.enqueue(payload('removal', 'already persisted elsewhere'))
await waitFor(() => !!sabotage, 'removal failure sabotage')
await waitFor(async () => {
try {
const current = await queue.get('removal')
return current.paused && current.items[0]?.status === 'unknown'
} catch {
return false
}
}, 'removal uncertainty fallback')
const snapshot = await queue.get('removal')
assert.equal(snapshot.items[0].status, 'unknown', 'acknowledged delivery must become unknown when queue cleanup cannot be persisted')
assert.equal(snapshot.paused, true, 'an unconfirmed queue cleanup must pause automatic dispatch')
assert.equal((await queue.get('bystander')).items[0].payload.text, 'must survive fallback')
const unknown = snapshot.items[0]
await fs.rm(file, { recursive: true, force: true })
const taken = await queue.take('removal', unknown.id)
assert.equal(taken.status, 'unknown')
assert.equal(taken.payload.text, 'already persisted elsewhere')
assert.deepEqual((await queue.get('removal')).items, [])
assert.equal((await queue.get('bystander')).items[0].payload.text, 'must survive fallback')
})
await verify('stale owner nonce is recovered without trusting a reused PID', async () => {
const file = join(root, 'owner-nonce.json')
await fs.writeFile(file, JSON.stringify({
version: 1,
queues: [{
sessionId: 'owner',
revision: 2,
paused: false,
items: [{
id: 'owned-item', createdAt: 1, status: 'delivering',
deliveryOwner: { pid: process.pid, nonce: 'old-process-instance', hostname: hostname(), startedAt: 1 },
payload: payload('owner', 'recover me')
}]
}]
}))
const snapshot = await createPromptQueue(options(file)).get('owner')
assert.equal(snapshot.items[0].status, 'unknown')
assert.equal(snapshot.paused, true)
})
await verify('stale and expired lock files are reclaimed', async () => {
const file = join(root, 'lock-reclaim.json')
const lock = `${file}.lock`
await fs.writeFile(lock, JSON.stringify({
pid: 99999999, nonce: 'dead-owner', hostname: hostname(),
createdAt: Date.now(), expiresAt: Date.now() + 60_000
}))
assert.deepEqual((await createPromptQueue(options(file)).get('lock')).items, [])
await fs.writeFile(lock, JSON.stringify({
pid: process.pid, nonce: 'expired-owner', hostname: 'remote-test-host',
createdAt: Date.now() - 60_000, expiresAt: Date.now() - 1
}))
assert.deepEqual((await createPromptQueue(options(file)).get('lock')).items, [])
})
await verify('live foreign lock times out with an explicit error', async () => {
const file = join(root, 'lock-timeout.json')
const lock = `${file}.lock`
await fs.writeFile(lock, JSON.stringify({
pid: process.pid, nonce: 'live-foreign-owner', hostname: hostname(),
createdAt: Date.now(), expiresAt: Date.now() + 60_000
}))
try {
await assert.rejects(createPromptQueue(options(file)).get('lock'), /文件锁超时/)
} finally {
await fs.rm(lock, { force: true })
}
})
await verify('blocked notify does not create a state revision', async () => {
const file = join(root, 'blocked-revision.json')
const queue = createPromptQueue(options(file, { blocked: () => true }))
await queue.enqueue(payload('revision', 'pending'))
await tick(50)
const before = await queue.get('revision')
queue.notify('revision')
await tick(50)
const after = await queue.get('revision')
assert.equal(after.revision, before.revision, 'a blocked no-op must not persist or notify a new revision')
})
await verify('host resolver applies dispatch-time permission downgrade without mutating stored payload', async () => {
const file = join(root, 'permission.json')
let blocked = true
let deliveredPermission: SendPayload['permissionMode']
assert.deepEqual(resolveQueuedRequestSettings({
queued: false, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox'
}), { permissionMode: 'full-access', persistSessionPermission: true })
assert.deepEqual(resolveQueuedRequestSettings({
queued: true, payloadPermissionMode: 'full-access', sessionPermissionMode: 'default-sandbox'
}), { permissionMode: 'default-sandbox', persistSessionPermission: false })
const queue = createPromptQueue(options(file, {
blocked: () => blocked,
resolveDispatch: value => ({
...value,
permissionMode: resolveQueuedRequestSettings({
queued: true,
payloadPermissionMode: value.permissionMode,
sessionPermissionMode: 'default-sandbox'
}).permissionMode
}),
deliver: async value => {
deliveredPermission = value.permissionMode
return { completion: Promise.resolve({ status: 'done' }) }
}
}))
await queue.enqueue(payload('permission', 'run', { permissionMode: 'full-access' }))
assert.equal((await queue.get('permission')).items[0].payload.permissionMode, 'full-access')
blocked = false
queue.notify('permission')
await waitFor(() => deliveredPermission !== undefined, 'permission delivery')
assert.equal(deliveredPermission, 'default-sandbox')
await waitFor(async () => (await queue.get('permission')).items.length === 0, 'permission delivery cleanup')
})
} finally {
if (!resolve(root).startsWith(workspace + '\\') && !resolve(root).startsWith(workspace + '/')) {
throw new Error('Unsafe adversarial test cleanup path')
}
await fs.rm(root, { recursive: true, force: true })
}
const failures = results.filter(result => !result.pass)
console.log(JSON.stringify({ result: failures.length ? 'FAIL' : 'PASS', checks: results }, null, 2))
if (failures.length) process.exitCode = 1
+121
View File
@@ -0,0 +1,121 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { resolve, join } from 'node:path'
import { createPromptQueue } from '../src/main/prompt-queue'
import type { PromptQueueSnapshot } from '../src/shared/prompt-queue'
const workspace = resolve('.tmp')
await fs.mkdir(workspace, { recursive: true })
const root = await fs.mkdtemp(join(workspace, 'prompt-queue-'))
const tick = () => new Promise(resolve => setTimeout(resolve, 10))
try {
let blocked = true
const delivered: string[] = []
const changes: PromptQueueSnapshot[] = []
let finish: ((value: { status: 'done' | 'cancelled' }) => void) | undefined
const options = {
file: join(root, 'queues.json'),
validate: async () => {},
blocked: async () => blocked,
changed: (snapshot: PromptQueueSnapshot) => { changes.push(snapshot) },
deliver: async (_payload: unknown, id: string) => {
delivered.push(id)
return { completion: new Promise<{ status: 'done' | 'cancelled' }>(resolve => { finish = resolve }) }
}
}
const queue = createPromptQueue(options)
for (let index = 0; index < 19; index++) await queue.enqueue({ sessionId: 'a', text: `${index}`, resources: ['C:/workspace/a'], skillIds: ['skill'], mcpServerIds: ['server'], quotes: [], attachments: [] })
await assert.rejects(queue.enqueue({ sessionId: 'a', text: 'full' }), /19/)
assert.equal((await queue.get('a')).items.length, 19)
const order = (await queue.get('a')).items.map(item => item.id).reverse()
await queue.reorder('a', order)
await assert.rejects(queue.reorder('a', [...order.slice(1), order[1]]), /排序/)
const taken = await queue.take('a', order[0])
assert.deepEqual(taken.payload.skillIds, ['skill'])
assert.deepEqual(taken.payload.mcpServerIds, ['server'])
assert.deepEqual(taken.payload.resources, ['C:/workspace/a'])
assert.equal((await queue.get('b')).items.length, 0, 'sessions are isolated')
assert.equal(delivered.length, 0, 'active run blocks ordinary dispatch')
const recovered = createPromptQueue(options)
assert.equal((await recovered.get('a')).paused, true, 'restart requires explicit resume')
blocked = false
await recovered.resume('a')
for (let attempt = 0; attempt < 100 && !delivered.length; attempt++) await tick()
assert.equal(delivered.length, 1)
for (let attempt = 0; attempt < 100 && (await recovered.get('a')).items.length === 18; attempt++) await tick()
assert.equal((await recovered.get('a')).items.length, 17, 'only acknowledged delivery leaves the queue')
finish!({ status: 'cancelled' })
for (let attempt = 0; attempt < 100 && !(await recovered.get('a')).paused; attempt++) await tick()
assert.equal((await recovered.get('a')).paused, true)
assert.equal(delivered.length, 1, 'stop pauses all remaining ordinary sends')
const uncertain = createPromptQueue({ ...options, file: join(root, 'uncertain.json'),
deliver: async () => { throw new Error('lost acknowledgement') } })
await uncertain.enqueue({ sessionId: 'c', text: 'uncertain' })
for (let attempt = 0; attempt < 100 && !(await uncertain.get('c')).paused; attempt++) await tick()
assert.equal((await uncertain.get('c')).items[0].status, 'unknown')
await assert.rejects(uncertain.resume('c'), /结果不明/)
await assert.rejects(uncertain.sendNow('c', 'unknown'), /尚未接入/)
const disk = JSON.parse(await fs.readFile(options.file, 'utf8'))
const interruptedItem = disk.queues[0].items.find((item: { status: string }) => item.status !== 'editing')
interruptedItem.status = 'delivering'
delete interruptedItem.deliveryOwner
await fs.writeFile(options.file, JSON.stringify(disk))
const interrupted = createPromptQueue(options)
assert.equal((await interrupted.get('a')).items[0].status, 'unknown')
await assert.rejects(interrupted.resume('a'), /结果不明/)
assert.ok(changes.every(snapshot => snapshot.revision >= 1))
const editFile = join(root, 'editing.json')
const editing = createPromptQueue({ ...options, file: editFile, blocked: async () => true })
await editing.enqueue({ sessionId: 'edit', text: 'original', skillIds: ['skill'] })
const originalId = (await editing.get('edit')).items[0].id
const draft = await editing.take('edit', originalId)
assert.equal(draft.payload.queueEditId, originalId)
assert.equal((await editing.get('edit')).items.length, 0)
assert.equal((await editing.get('edit')).editingDraft?.payload.text, 'original', 'take remains recoverable on disk until submission')
await editing.restoreEdit('edit', originalId)
assert.equal((await editing.get('edit')).items[0].id, originalId, 'rollback preserves identity and order')
const retaken = await editing.take('edit', originalId)
await editing.enqueue({ ...retaken.payload, text: 'edited' })
assert.equal((await editing.get('edit')).items[0].payload.text, 'edited')
assert.equal((await editing.get('edit')).editingDraft, undefined)
await assert.rejects(editing.enqueue({ sessionId: 'edit', text: 'invalid handoff', queueEditId: '' }), /编辑取回标识/)
const ordered = createPromptQueue({ ...options, file: join(root, 'edit-order.json'), blocked: async () => true })
for (const text of ['first', 'second', 'third']) await ordered.enqueue({ sessionId: 'edit-order', text })
const middle = (await ordered.get('edit-order')).items[1]
const middleDraft = await ordered.take('edit-order', middle.id)
await ordered.enqueue({ ...middleDraft.payload, text: 'second edited' })
assert.deepEqual((await ordered.get('edit-order')).items.map(item => item.payload.text),
['first', 'second edited', 'third'], 'editing and resubmitting keeps the original queue position')
const toRecover = (await editing.get('edit')).items[0].id
await editing.take('edit', toRecover)
const editingDisk = JSON.parse(await fs.readFile(editFile, 'utf8'))
editingDisk.queues[0].items[0].deliveryOwner.nonce = 'previous-process'
await fs.writeFile(editFile, JSON.stringify(editingDisk))
assert.equal((await createPromptQueue({ ...options, file: editFile }).get('edit')).items[0].payload.text, 'edited', 'restart recovers an unsubmitted editor handoff')
const guideAcks = new Map<string, { resolve(): void; reject(error: Error): void }>()
const immediate = createPromptQueue({ ...options, file: join(root, 'immediate.json'), blocked: async () => true,
deliverNow: (_payload, id) => new Promise<void>((resolve, reject) => { guideAcks.set(id, { resolve, reject }) }) })
await immediate.enqueue({ sessionId: 'guide', text: 'a' })
await immediate.enqueue({ sessionId: 'guide', text: 'b' })
const guideIds = (await immediate.get('guide')).items.map(item => item.id)
const firstGuide = immediate.sendNow('guide', guideIds[0])
const secondGuide = immediate.sendNow('guide', guideIds[1])
const secondFailure = assert.rejects(secondGuide, /cancelled/)
for (let attempt = 0; attempt < 100 && guideAcks.size !== 2; attempt++) await tick()
assert.equal(guideAcks.size, 2)
assert.ok((await immediate.get('guide')).items.every(item => item.status === 'delivering'))
guideAcks.get(guideIds[0])!.resolve()
await firstGuide
assert.equal((await immediate.get('guide')).items[0].id, guideIds[1], 'one acknowledgement cannot consume another input')
guideAcks.get(guideIds[1])!.reject(Object.assign(new Error('cancelled'), { submitted: true }))
await secondFailure
assert.equal((await immediate.get('guide')).items[0].status, 'unknown')
assert.equal((await immediate.get('guide')).paused, true)
console.log('prompt-queue-regression: cap, payload, order, isolation, delivery, stop, recovery and unknown checks passed')
} finally {
if (!resolve(root).startsWith(workspace + '\\') && !resolve(root).startsWith(workspace + '/')) throw new Error('Unsafe test cleanup path')
await fs.rm(root, { recursive: true, force: true })
}
@@ -0,0 +1,493 @@
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { randomUUID, createHash } from 'node:crypto'
import { build } from 'esbuild'
import { zipSync } from 'fflate'
const testRoot = await fs.mkdtemp(path.resolve('.tmp/recommended-skills-'))
const tempRoot = path.join(testRoot, 'installs')
await fs.mkdir(tempRoot, { recursive: true })
let userData = path.join(testRoot, 'data')
await fs.mkdir(userData, { recursive: true })
;(globalThis as Record<string, unknown>).__ycodeUserData = userData
const checks: string[] = []
const check = (name: string, actual: unknown, expected: unknown): void => {
assert.deepEqual(actual, expected, name)
checks.push(name)
}
const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex')
/** Each group gets its own skill store so installed state never leaks between cases. */
async function useGroup(name: string): Promise<void> {
userData = path.join(testRoot, `data-${name}`)
await fs.mkdir(userData, { recursive: true })
;(globalThis as Record<string, unknown>).__ycodeUserData = userData
}
const electronStub = {
name: 'isolated-electron-path',
setup(context: import('esbuild').PluginBuild) {
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'capability-test' }))
context.onLoad({ filter: /.*/, namespace: 'capability-test' }, () => ({
loader: 'js',
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return globalThis.__ycodeUserData}};
export const safeStorage={isEncryptionAvailable:()=>false,encryptString:()=>{throw Error('unused')},decryptString:()=>{throw Error('unused')}};`
}))
}
}
async function compile(entry: string, file: string): Promise<string> {
const outfile = path.join(testRoot, file)
await build({ entryPoints: [path.resolve(entry)], outfile, bundle: true, platform: 'node', format: 'esm',
packages: 'external', logLevel: 'warning', plugins: [electronStub] })
return outfile
}
const servicePath = await compile('src/main/capabilities/recommended-skills.ts', 'recommended-skills.mjs')
const skillsPath = await compile('src/main/capabilities/skills.ts', 'skills.mjs')
const service = await import(pathToFileURL(servicePath).href)
const skills = await import(pathToFileURL(skillsPath).href)
interface FakeEntry { name: string; description?: string; version?: string; license?: string }
function archiveFor(entry: FakeEntry, extra: Record<string, string> = {}): Uint8Array {
const front = ['---', `name: ${entry.name}`, `description: ${entry.description ?? 'Controlled recommended skill'}`,
...(entry.version ? [`version: "${entry.version}"`] : []), ...(entry.license ? [`license: ${entry.license}`] : []), '---'].join('\n')
const files: Record<string, Uint8Array> = {
'skill/SKILL.md': new TextEncoder().encode(`${front}\n\nControlled body.\n`),
'skill/LICENSE': new TextEncoder().encode('MIT License\n\nPermission is hereby granted, free of charge.\n')
}
for (const [name, value] of Object.entries(extra)) files[`skill/${name}`] = new TextEncoder().encode(value)
return zipSync(files, { level: 0 })
}
/** A local skill folder that is not a recommendation package. */
async function makeLocalSkill(name: string, description = 'Locally created skill'): Promise<string> {
const folder = path.join(testRoot, `local-${name}-${randomUUID().slice(0, 8)}`)
await fs.mkdir(folder, { recursive: true })
await fs.writeFile(path.join(folder, 'SKILL.md'),
`---\nname: ${name}\ndescription: ${description}\n---\nLocal body.\n`)
return folder
}
const baseEntry = { id: 'archify', name: 'archify', description: 'Controlled', author: 'tt-a1i', license: 'MIT',
repository: 'tt-a1i/archify', version: '3.2.0', commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083',
packagePath: 'archify.zip', expectedBytes: 0, containsScripts: true }
const archive = archiveFor({ name: 'archify', version: '3.2.0', license: 'MIT' })
const archiveHash = sha256(archive)
const catalog = [{ ...baseEntry, sha256: archiveHash, expectedBytes: archive.byteLength }]
function fakeResponse(body: Uint8Array, init: { status?: number; headers?: Record<string, string> } = {}): Response {
return new Response(new Uint8Array(body), { status: init.status ?? 200,
headers: { 'content-length': String(body.byteLength), ...(init.headers ?? {}) } })
}
type FakeRequest = (url: URL, init?: RequestInit) => Promise<Response>
/** A response that never completes until the caller's abort signal fires. */
function stallingResponse(totalBytes: number, chunk: Uint8Array, init?: RequestInit): Response {
let streamController: ReadableStreamDefaultController<Uint8Array> | undefined
let timer: ReturnType<typeof setInterval> | undefined
const stream = new ReadableStream({
start(controller) {
streamController = controller
// The interval stays referenced so the test event loop keeps running.
timer = setInterval(() => { try { controller.enqueue(chunk) } catch { /* aborted */ } }, 20)
}
})
init?.signal?.addEventListener('abort', () => {
if (timer) clearInterval(timer)
try { streamController?.error(Object.assign(new Error('aborted'), { name: 'AbortError' })) } catch { /* already closed */ }
})
return new Response(stream, { headers: { 'content-length': String(totalBytes) } })
}
function makeInstaller(request: FakeRequest, overrides: Partial<{
catalog: typeof catalog; timeoutMs: number
}> = {}) {
return service.createRecommendedSkillInstaller({
tempRoot, catalog: overrides.catalog ?? catalog, request, timeoutMs: overrides.timeoutMs ?? 5000,
snapshot: skills.getSkillInstallSnapshot, importArchive: skills.importRecommendedSkillArchive
})
}
// ---- unknown and duplicate operation identifiers -----------------------------
{
await useGroup('ids')
const installer = makeInstaller(async () => fakeResponse(archive))
await assert.rejects(installer.install('missing', { operationId: randomUUID() }), /推荐技能不存在/)
check('an unknown recommendation id is rejected', true, true)
const operationId = randomUUID()
const first = installer.install('archify', { operationId })
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /已有推荐技能正在安装/)
check('a second install is rejected while one is active', true, true)
await first
await assert.rejects(installer.install('archify', { operationId }), /不能重复执行/)
check('a reused operation id cannot run twice', true, true)
}
// ---- download failures --------------------------------------------------------
{
await useGroup('official-api')
const installer = makeInstaller(async (input, init) => {
const url = new URL(String(input))
check('download uses the official GitHub contents API', url.origin, 'https://api.github.com')
check('download pins the complete commit', url.searchParams.get('ref'), catalog[0].commit)
check('download requests raw bytes rather than JSON metadata', new Headers(init?.headers).get('accept'), 'application/vnd.github.raw+json')
return fakeResponse(archive)
})
await installer.install('archify', { operationId: randomUUID() })
}
{
await useGroup('api-rate-limit')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 403, headers: { 'x-ratelimit-remaining': '0' } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /限额/)
check('GitHub API rate limits are actionable failures', installer.state()?.phase, 'failed')
}
{
await useGroup('http-404')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 404 }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /HTTP 404/)
check('a failed download is reported as a failure', installer.state()?.phase, 'failed')
}
{
await useGroup('redirect-host')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 302, headers: { location: 'https://evil.example.com/archify.zip' } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /不允许的地址/)
check('a redirect to another host is refused', installer.state()?.phase, 'failed')
}
{
await useGroup('redirect-http')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 302, headers: { location: 'http://raw.githubusercontent.com/x' } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /不允许的地址/)
check('a plain http redirect is refused', installer.state()?.phase, 'failed')
}
{
await useGroup('redirect-loop')
const installer = makeInstaller(async () => fakeResponse(archive, { status: 302, headers: { location: 'https://raw.githubusercontent.com/a/b/c' } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /重定向过多/)
check('too many redirects are refused', installer.state()?.phase, 'failed')
}
{
await useGroup('disconnect')
const installer = makeInstaller(async () => { throw Error('socket hang up') })
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /无法连接 GitHub.*网络或代理/)
check('a disconnected transport is reported', installer.state()?.phase, 'failed')
}
{
await useGroup('timeout')
const installer = makeInstaller(async (_url, init) => {
return stallingResponse(archive.byteLength, archive.subarray(0, 16), init)
}, { timeoutMs: 60 })
const operationId = randomUUID()
const pending = installer.install('archify', { operationId })
await assert.rejects(pending, /超时/)
check('a stalled download times out', installer.state()?.phase, 'failed')
}
{
await useGroup('oversize')
const oversize = new Uint8Array(21 * 1024 * 1024)
const installer = makeInstaller(async () => new Response(new ReadableStream({
start(controller) { controller.enqueue(oversize); controller.close() }
}), { headers: { 'content-length': String(oversize.byteLength) } }))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /超过 20MB/)
check('an oversized package is refused', installer.state()?.phase, 'failed')
}
{
await useGroup('hash')
const wrong = archiveFor({ name: 'archify', version: '3.2.0', license: 'MIT' })
wrong[0] = wrong[0] ^ 0xff
const installer = makeInstaller(async () => fakeResponse(wrong))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /SHA-256 校验失败/)
check('a hash mismatch is refused', installer.state()?.phase, 'failed')
}
{
await useGroup('truncated')
const installer = makeInstaller(async () => fakeResponse(archive.subarray(0, archive.byteLength - 3)))
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /大小与目录不符/)
check('a truncated package is refused', installer.state()?.phase, 'failed')
}
// ---- cancellation -------------------------------------------------------------
{
await useGroup('cancel')
const installer = makeInstaller(async (_url, init) => stallingResponse(archive.byteLength, archive.subarray(0, 64), init))
const operationId = randomUUID()
const pending = installer.install('archify', { operationId })
await new Promise(resolve => setTimeout(resolve, 40))
const cancelled = installer.cancel(operationId)
check('a running download can be cancelled', cancelled.cancelled, true)
await assert.rejects(pending, /取消/)
check('a cancelled install never reports success', installer.state()?.phase, 'cancelled')
check('a cancelled install is not reported as failed', installer.state()?.phase === 'failed', false)
}
// ---- progress ordering --------------------------------------------------------
{
await useGroup('progress')
const seen: string[] = []
const installer = makeInstaller(async () => fakeResponse(archive))
installer.subscribe(progress => seen.push(progress.phase))
await installer.install('archify', { operationId: randomUUID() })
check('progress reaches downloading, validating and installed',
['downloading', 'validating', 'installed'].every(phase => seen.includes(phase)), true)
const stamps = seen.length
const state = installer.state()
check('the install state is a snapshot object', typeof state?.updatedAt, 'number')
check('progress events are emitted at least three times', stamps >= 3, true)
}
// ---- shutdown blocks new work and initialize restores it -----------------------
{
await useGroup('shutdown')
const installer = makeInstaller(async () => fakeResponse(archive))
await installer.stop()
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /正在停止/)
check('a stopped service refuses new installs', true, true)
await installer.initialize()
const installed = await installer.install('archify', { operationId: randomUUID() })
check('initialize restores the service after a stop', installed.name, 'archify')
}
// ---- real skill store: first install, origin and idempotency --------------------
{
await useGroup('first-install')
let downloads = 0
const installer = makeInstaller(async () => { downloads += 1; return fakeResponse(archive) })
const installed = await installer.install('archify', { operationId: randomUUID() })
check('the first install returns the installed skill', installed.name, 'archify')
check('the first install records the recommendation origin', installed.origin?.recommendationId, 'archify')
check('the first install records the archive hash', installed.origin?.archiveSha256, archiveHash)
check('the first install records the commit', installed.origin?.commit, catalog[0].commit)
check('the first install is enabled', installed.enabled, true)
const listed = await skills.listSkills()
check('the installed skill appears in the skill list', listed.length, 1)
check('the listed skill keeps the origin', listed[0].origin?.recommendationId, 'archify')
check('the first install downloaded exactly once', downloads, 1)
const again = await installer.install('archify', { operationId: randomUUID() })
check('an unchanged same-version install is idempotent', again.origin?.recommendationId, 'archify')
check('an unchanged same-version install does not download again', downloads, 1)
}
// ---- conflicting local skill requires explicit overwrite -----------------------
{
await useGroup('conflict')
const installer = makeInstaller(async () => fakeResponse(archive))
const local = await makeLocalSkill('archify')
await skills.importSkill(local, true)
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /已存在同名技能/)
check('a same-name local skill blocks an unconfirmed install', true, true)
const replaced = await installer.install('archify', { operationId: randomUUID(), overwrite: true })
check('an authorized overwrite installs the recommended package', replaced.origin?.recommendationId, 'archify')
}
// ---- a manual import clears a previous recommendation origin -------------------
{
await useGroup('manual-clears-origin')
const installer = makeInstaller(async () => fakeResponse(archive))
const installed = await installer.install('archify', { operationId: randomUUID() })
check('the recommended install records an origin', installed.origin?.recommendationId, 'archify')
const folder = path.join(userData, 'capabilities', 'skills', 'packages')
const entries = await fs.readdir(folder)
const managed = entries.find(name => !name.startsWith('.'))!
const manual = await makeLocalSkill('archify', 'Manual replacement')
await skills.importSkill(manual, true)
const listed = await skills.listSkills()
check('a manual import keeps the same skill id', listed.length, 1)
check('a manual import clears the recommendation origin', listed[0].origin, undefined)
check('a manual import is not flagged as locally edited', listed[0].userModified, false)
check('a manual import keeps the local description', listed[0].description, 'Manual replacement')
void managed
}
// ---- a disabled skill keeps its disabled state across a replacement -----------
{
await useGroup('disabled-retained')
const installer = makeInstaller(async () => fakeResponse(archive))
const installed = await installer.install('archify', { operationId: randomUUID() })
await skills.setSkillEnabled(installed.id, false)
const disabled = (await skills.listSkills())[0]
check('the skill can be disabled', disabled.enabled, false)
await installer.install('archify', { operationId: randomUUID(), overwrite: true })
const after = (await skills.listSkills())[0]
check('a replacement keeps the disabled state', after.enabled, false)
check('a replacement keeps the recommendation origin', after.origin?.recommendationId, 'archify')
}
// ---- package identity is enforced -------------------------------------------
{
await useGroup('package-identity')
const wrongVersion = archiveFor({ name: 'archify', version: '9.9.9', license: 'MIT' })
const versionInstaller = makeInstaller(async () => fakeResponse(wrongVersion),
{ catalog: [{ ...catalog[0], sha256: sha256(wrongVersion), expectedBytes: wrongVersion.byteLength }] })
await assert.rejects(versionInstaller.install('archify', { operationId: randomUUID() }), /名称、版本或许可证与目录不符/)
check('a package with another version is refused', versionInstaller.state()?.phase, 'failed')
}
{
await useGroup('package-license')
const wrongLicense = archiveFor({ name: 'archify', version: '3.2.0', license: 'Apache-2.0' })
const licenseInstaller = makeInstaller(async () => fakeResponse(wrongLicense),
{ catalog: [{ ...catalog[0], sha256: sha256(wrongLicense), expectedBytes: wrongLicense.byteLength }] })
await assert.rejects(licenseInstaller.install('archify', { operationId: randomUUID() }), /名称、版本或许可证与目录不符/)
check('a package with another license is refused', licenseInstaller.state()?.phase, 'failed')
}
{
await useGroup('package-traversal')
const traversal = zipSync({ 'skill/SKILL.md': new TextEncoder().encode('---\nname: archify\ndescription: x\nversion: "3.2.0"\nlicense: MIT\n---\n'),
'../escape.txt': new TextEncoder().encode('nope') }, { level: 0 })
const traversalInstaller = makeInstaller(async () => fakeResponse(traversal),
{ catalog: [{ ...catalog[0], sha256: sha256(traversal), expectedBytes: traversal.byteLength }] })
await assert.rejects(traversalInstaller.install('archify', { operationId: randomUUID() }))
check('a package with a path traversal entry is refused', traversalInstaller.state()?.phase, 'failed')
}
{
await useGroup('package-no-skill')
const noSkill = zipSync({ 'skill/readme.md': new TextEncoder().encode('nothing here') }, { level: 0 })
const noSkillInstaller = makeInstaller(async () => fakeResponse(noSkill),
{ catalog: [{ ...catalog[0], sha256: sha256(noSkill), expectedBytes: noSkill.byteLength }] })
await assert.rejects(noSkillInstaller.install('archify', { operationId: randomUUID() }))
check('a package without SKILL.md is refused', noSkillInstaller.state()?.phase, 'failed')
}
// ---- a changed local skill is detected at commit time ------------------------
{
await useGroup('commit-conflict')
let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve })
let calls = 0
const installer = makeInstaller(async () => {
calls += 1
if (calls > 1) await gate
return fakeResponse(archive)
})
const installed = await installer.install('archify', { operationId: randomUUID() })
const skillFolder = path.join(userData, 'capabilities', 'skills', 'packages')
const managed = (await fs.readdir(skillFolder)).find(name => !name.startsWith('.'))!
await fs.writeFile(path.join(skillFolder, managed, 'SKILL.md'), '---\nname: archify\ndescription: edited locally\n---\nedited\n')
const pending = installer.install('archify', { operationId: randomUUID(), overwrite: true })
await new Promise(resolve => setTimeout(resolve, 30))
await fs.writeFile(path.join(skillFolder, managed, 'SKILL.md'), '---\nname: archify\ndescription: edited during download\n---\nedited\n')
release!()
await assert.rejects(pending, /已变化,请重新确认替换/)
check('a skill edited during the download is not overwritten silently', true, true)
const listed = await skills.listSkills()
check('the locally edited skill is preserved', listed[0].description, 'edited during download')
void installed
}
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, phase: 'archive' }))
// ---- a registry write failure must roll the install back ----------------------
{
await useGroup('registry-failure')
const installer = makeInstaller(async () => fakeResponse(archive))
const installed = await installer.install('archify', { operationId: randomUUID() })
check('the baseline install succeeds before the injected failure', installed.origin?.recommendationId, 'archify')
// A locally edited skill bypasses the idempotent fast path, so the import really runs.
const packages = path.join(userData, 'capabilities', 'skills', 'packages')
const managed = (await fs.readdir(packages)).find(name => !name.startsWith('.'))!
await fs.writeFile(path.join(packages, managed, 'SKILL.md'),
'---\nname: archify\ndescription: locally edited before the failure\n---\nedited\n')
const registryFile = path.join(userData, 'capabilities', 'skills', 'registry.json')
const before = await fs.readFile(registryFile, 'utf8')
const realRename = fs.rename
let failed = false
;(fs as unknown as { rename: typeof fs.rename }).rename = (async (from: string, to: string) => {
if (!failed && String(to).endsWith('registry.json')) { failed = true; throw Error('injected registry write failure') }
return realRename(from, to)
}) as typeof fs.rename
try {
await assert.rejects(installer.install('archify', { operationId: randomUUID(), overwrite: true }), /injected registry write failure/)
} finally {
;(fs as unknown as { rename: typeof fs.rename }).rename = realRename
}
check('a registry write failure is reported to the caller', failed, true)
const after = await fs.readFile(registryFile, 'utf8')
check('a failed registry write leaves the previous registry intact', after, before)
const listed = await skills.listSkills()
check('a failed registry write keeps the previously installed skill', listed.length, 1)
check('a failed registry write keeps the previous origin', listed[0].origin?.recommendationId, 'archify')
}
// ---- the commit stage refuses cancellation -----------------------------------
{
await useGroup('commit-cancel')
let commitEntered: (() => void) | undefined
const commitGate = new Promise<void>(resolve => { commitEntered = resolve })
const installer = service.createRecommendedSkillInstaller({
tempRoot, catalog, request: async () => fakeResponse(archive), timeoutMs: 5000,
snapshot: skills.getSkillInstallSnapshot,
importArchive: async (source, recommendation, overwrite, fingerprint, beforeCommit) => {
const real = skills.importRecommendedSkillArchive
// Enter the commit phase and hold it, so the cancel lands on an uncommitted-free stage.
const pending = real(source, recommendation, overwrite, fingerprint, () => {
beforeCommit()
commitEntered?.()
})
await commitGate
return pending
}
})
const operationId = randomUUID()
const pending = installer.install('archify', { operationId })
await commitGate
const cancelled = installer.cancel(operationId)
check('a cancel during the commit stage is refused', cancelled.cancelled, false)
const installed = await pending
check('a refused cancel still completes the install', installed.origin?.recommendationId, 'archify')
}
// ---- stop cancels a pre-commit install and later restores the service --------
{
await useGroup('stop-cancel')
let stall = true
const installer = makeInstaller(async (_url, init) => stall
? stallingResponse(archive.byteLength, archive.subarray(0, 32), init)
: fakeResponse(archive))
const operationId = randomUUID()
const pending = installer.install('archify', { operationId })
await new Promise(resolve => setTimeout(resolve, 30))
await installer.stop()
await assert.rejects(pending, /取消/)
check('stop cancels an install that has not committed', installer.state()?.phase, 'cancelled')
await assert.rejects(installer.install('archify', { operationId: randomUUID() }), /正在停止/)
check('a stopped service refuses a new install', true, true)
await installer.initialize()
stall = false
const restored = await installer.install('archify', { operationId: randomUUID() })
check('initialize restores installs after a stop', restored.origin?.recommendationId, 'archify')
}
// ---- the shipped catalog entry must match the real published package ---------
{
const cached = path.resolve('.tmp/recommended-audit/archify.zip')
const override = process.env.YCODE_ARCHIFY_ZIP ? path.resolve(process.env.YCODE_ARCHIFY_ZIP) : undefined
let available = true
let bytes: Uint8Array | undefined
try {
bytes = await fs.readFile(override ?? cached)
} catch {
// Offline by default: the published package is audited by a separate network test.
available = false
}
if (available) {
const catalogModule = await import(pathToFileURL(await compile('src/main/capabilities/skill-recommendations.ts', 'catalog.mjs')).href)
const entry = catalogModule.SKILL_RECOMMENDATIONS.find((item: { id: string }) => item.id === 'archify')
check('the shipped catalog contains the archify recommendation', !!entry, true)
check('the shipped catalog hash matches the published package', entry.sha256, sha256(bytes))
check('the shipped catalog size matches the published package', entry.expectedBytes, bytes.byteLength)
const written = path.join(testRoot, 'real-archify.zip')
await fs.writeFile(written, bytes)
await useGroup('real-package')
const installer = makeInstaller(async () => fakeResponse(bytes),
{ catalog: [{ ...catalog[0], sha256: entry.sha256, expectedBytes: entry.expectedBytes }] })
const installed = await installer.install('archify', { operationId: randomUUID() })
check('the published package installs through the real import path', installed.name, 'archify')
check('the published package keeps its recommendation origin', installed.origin?.recommendationId, 'archify')
const document = await skills.readSkill(installed.id)
check('the published package keeps its resource files', document.files.length > 100, true)
} else {
console.log(JSON.stringify({ result: 'SKIP', phase: 'real-package', reason: 'published package not present in .tmp' }))
}
}
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, phase: 'real-package' }))
+437
View File
@@ -0,0 +1,437 @@
import assert from 'node:assert/strict'
import { nextRequestSettings } from '../src/main/agent/request-settings'
import { useChatStore } from '../src/renderer/src/stores/chat'
import type { ActiveChatRun, SendPayload, SendResult } from '../src/shared/ipc'
import type { ChatEvent, ChatMessage, ContextUsageSnapshot, SessionMeta } from '../src/shared/types'
import type { SessionContext } from '../src/shared/workspaces'
interface Deferred<T> {
promise: Promise<T>
resolve(value: T): void
reject(reason?: unknown): void
}
interface FakeBackend {
api: FakeApi
sessions: SessionMeta[]
messages: Record<string, ChatMessage[]>
replaceSession(session: SessionMeta): void
}
interface FakeApi {
session: {
list(): Promise<SessionMeta[]>
create(workspaceId: string | undefined, context?: SessionContext): Promise<SessionMeta>
messages(sessionId: string): Promise<ChatMessage[]>
updateContext(sessionId: string, patch: SessionContext): Promise<SessionMeta>
remove(sessionId: string, options?: { onlyIfEmpty?: boolean }): Promise<void>
rename(sessionId: string, title: string): Promise<void>
}
chat: {
send(payload: SendPayload): Promise<SendResult>
setSessionVisible(sessionId: string | null): Promise<{ accepted: boolean }>
cancel(runId: string): Promise<{ cancelled: boolean }>
revert(sessionId: string, messageId: string, options?: unknown): Promise<{ messages: ChatMessage[] }>
respond(runId: string, interactionId: string, response: unknown): Promise<{ accepted: boolean }>
activeRun(sessionId: string): Promise<ActiveChatRun | null>
onEvent(listener: (event: ChatEvent) => void): () => void
onStarted(listener: (run: ActiveChatRun) => void): () => void
queue: {
get(sessionId: string): Promise<{ items: unknown[] }>
enqueue(payload: SendPayload): Promise<unknown>
}
}
}
function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((accept, decline) => {
resolve = accept
reject = decline
})
return { promise, resolve, reject }
}
async function flushAsync(): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, 0))
}
async function waitFor(description: string, predicate: () => boolean, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (predicate()) return
await new Promise<void>((resolve) => setTimeout(resolve, 5))
}
throw new Error(`timed out waiting for ${description}`)
}
function makeSession(id: string, overrides: Partial<SessionMeta> = {}): SessionMeta {
return { id, title: id, createdAt: 1, updatedAt: 1, ...overrides }
}
function makeMessage(id: string, role: ChatMessage['role'], content: string): ChatMessage {
return { id, role, content, createdAt: 1, status: 'done', ...(role === 'assistant' ? { tools: [], parts: [] } : {}) }
}
function makeSendResult(
runId: string,
modelId: string,
modelName: string,
contextUsage?: ContextUsageSnapshot
): SendResult {
const userMessage = makeMessage(`${runId}-user`, 'user', 'question')
const assistant: ChatMessage = {
id: `${runId}-assistant`,
role: 'assistant',
content: '',
tools: [],
parts: [],
requestId: runId,
modelId,
modelName,
contextUsage,
createdAt: 2,
turnStartedAt: userMessage.createdAt,
status: 'streaming'
}
return {
runId,
assistantMessageId: assistant.id,
modelId,
modelName,
userMessage,
messages: [userMessage, assistant]
}
}
function installFakeBackend(
sessionSeeds: SessionMeta[],
messageSeeds: Record<string, ChatMessage[]> = {}
): FakeBackend {
const sessions = sessionSeeds.map((item) => ({ ...item }))
const messages = Object.fromEntries(Object.entries(messageSeeds).map(([id, items]) => [id, [...items]]))
let createdSequence = 0
const api: FakeApi = {
session: {
list: async () => sessions.map((item) => ({ ...item })),
create: async (_workspaceId, context = {}) => {
const created = makeSession(`created-${++createdSequence}`, context)
sessions.unshift(created)
messages[created.id] = []
return { ...created }
},
messages: async (sessionId) => [...(messages[sessionId] ?? [])],
updateContext: async (sessionId, patch) => {
const index = sessions.findIndex((item) => item.id === sessionId)
if (index < 0) throw new Error(`unknown session ${sessionId}`)
const updated = { ...sessions[index], ...patch }
sessions[index] = updated
return { ...updated }
},
remove: async (sessionId, options) => {
const index = sessions.findIndex((item) => item.id === sessionId)
if (index < 0) return
if (options?.onlyIfEmpty && (messages[sessionId]?.length ?? 0) > 0) throw new Error('session is not empty')
sessions.splice(index, 1)
delete messages[sessionId]
},
rename: async (sessionId, title) => {
const index = sessions.findIndex((item) => item.id === sessionId)
if (index >= 0) sessions[index] = { ...sessions[index], title }
}
},
chat: {
send: async () => { throw new Error('fake send was not configured') },
setSessionVisible: async () => ({ accepted: true }),
cancel: async () => ({ cancelled: true }),
revert: async () => ({ messages: [] }),
respond: async () => ({ accepted: true }),
activeRun: async () => null,
onEvent: () => () => undefined,
onStarted: () => () => undefined,
queue: {
get: async () => ({ items: [] }),
enqueue: async () => undefined
}
}
}
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { ycode: api }
})
return {
api,
sessions,
messages,
replaceSession(session) {
const index = sessions.findIndex((item) => item.id === session.id)
if (index >= 0) sessions[index] = { ...session }
else sessions.unshift({ ...session })
}
}
}
function resetStore(
sessions: SessionMeta[],
currentSessionId: string | null,
messages: ChatMessage[] = [],
draftContext: SessionContext = {}
): void {
useChatStore.setState({
sessions,
currentSessionId,
messages,
sessionMessages: currentSessionId ? { [currentSessionId]: messages } : {},
sessionUsage: {},
sessionContextWindow: {},
runs: {},
runSequences: {},
sendingSessions: [],
running: null,
sending: false,
loading: false,
error: null,
draftContext,
draftId: 0
})
}
async function testSerialModelWritesAndFrozenRunningTurn(): Promise<void> {
const main = makeSession('main', { modelId: 'model-a', thinkingLevel: 'low' })
resetStore([main], main.id)
const backend = installFakeBackend([main], { [main.id]: [] })
const frozenUsage: ContextUsageSnapshot = {
usage: { inputTokens: 120, outputTokens: 30, totalTokens: 150 },
contextWindow: 64_000,
sampledAt: 10,
requestId: 'run-a',
modelId: 'model-a'
}
let sentPayload: SendPayload | undefined
backend.api.chat.send = async (payload) => {
sentPayload = payload
return makeSendResult('run-a', 'model-a', 'Model A', frozenUsage)
}
await useChatStore.getState().send('freeze current run')
assert.equal(sentPayload?.sessionId, main.id)
assert.equal(useChatStore.getState().running?.runId, 'run-a')
const writes: Array<{ id: string; patch: SessionContext; gate: Deferred<SessionMeta> }> = []
backend.api.session.updateContext = (sessionId, patch) => {
const gate = deferred<SessionMeta>()
writes.push({ id: sessionId, patch, gate })
return gate.promise
}
const resolveWrite = (index: number): void => {
const write = writes[index]
const current = backend.sessions.find((item) => item.id === write.id) ?? makeSession(write.id)
const updated = { ...current, ...write.patch }
backend.replaceSession(updated)
write.gate.resolve(updated)
}
const pendingA = useChatStore.getState().selectModel('model-a')
await waitFor('first serialized model write', () => writes.length === 1)
const pendingB = useChatStore.getState().selectModel('model-b')
const pendingC = useChatStore.getState().selectModel('model-c')
await flushAsync()
assert.equal(writes.length, 1, 'model writes must be serialized instead of racing')
resolveWrite(0)
await waitFor('second serialized model write', () => writes.length === 2)
resolveWrite(1)
await waitFor('third serialized model write', () => writes.length === 3)
resolveWrite(2)
await Promise.all([pendingA, pendingB, pendingC])
assert.deepEqual(writes.map((write) => write.patch.modelId), ['model-a', 'model-b', 'model-c'])
const state = useChatStore.getState()
assert.equal(state.sessions.find((item) => item.id === main.id)?.modelId, 'model-c')
const assistant = state.messages.find((message) => message.id === 'run-a-assistant')
assert.ok(assistant)
assert.equal(assistant.modelId, 'model-a', 'the active assistant must keep its starting model')
assert.equal(assistant.modelName, 'Model A')
assert.deepEqual(assistant.contextUsage?.usage, frozenUsage.usage)
assert.equal(assistant.contextUsage?.contextWindow, 64_000)
assert.deepEqual(state.sessionUsage[main.id], frozenUsage.usage)
assert.equal(state.sessionContextWindow[main.id], 64_000)
}
async function testSendFailureDoesNotPoisonRetry(): Promise<void> {
const main = makeSession('retry-session', { modelId: 'model-a' })
resetStore([main], main.id)
const backend = installFakeBackend([main], { [main.id]: [] })
let calls = 0
backend.api.chat.send = async () => {
calls++
if (calls === 1) throw new Error('mock send failure')
return makeSendResult('run-retry', 'model-a', 'Model A')
}
const fakeWindow = (globalThis as typeof globalThis & { window: { ycode: Record<string, unknown> } }).window
assert.equal('models' in fakeWindow.ycode, false, 'the fake window must not expose paid model APIs')
assert.equal('providers' in fakeWindow.ycode, false, 'the fake window must not expose provider APIs')
await assert.rejects(useChatStore.getState().send('first attempt'), /mock send failure/)
let state = useChatStore.getState()
assert.equal(state.sending, false)
assert.deepEqual(state.sendingSessions, [])
assert.deepEqual(state.messages, [])
assert.equal(state.error, 'mock send failure')
await useChatStore.getState().send('retry attempt')
state = useChatStore.getState()
assert.equal(calls, 2, 'the retry must reach the send boundary exactly once')
assert.equal(state.error, null)
assert.equal(state.running?.runId, 'run-retry')
assert.equal(state.messages.some((message) => message.id === 'run-retry-assistant'), true)
}
async function testLateResultAndEventStayInOriginalSession(): Promise<void> {
const first = makeSession('session-a', { modelId: 'model-a' })
const second = makeSession('session-b', { modelId: 'model-b' })
resetStore([first, second], first.id)
const secondMessage = makeMessage('session-b-history', 'user', 'keep this session')
const backend = installFakeBackend([first, second], {
[first.id]: [],
[second.id]: [secondMessage]
})
const response = deferred<SendResult>()
let sendStarted = false
backend.api.chat.send = async () => {
sendStarted = true
return response.promise
}
const sending = useChatStore.getState().send('slow send')
await waitFor('slow send to reach IPC', () => sendStarted)
await useChatStore.getState().selectSession(second.id)
assert.equal(useChatStore.getState().currentSessionId, second.id)
assert.deepEqual(useChatStore.getState().messages, [secondMessage])
response.resolve(makeSendResult('late-run', 'model-a', 'Model A'))
await sending
let state = useChatStore.getState()
assert.equal(state.currentSessionId, second.id, 'a late send response must not switch sessions')
assert.deepEqual(state.messages, [secondMessage], 'a late send response must not replace the selected session messages')
assert.equal(state.running, null, 'the original session run must not become the selected session run')
assert.equal(state.runs['late-run']?.sessionId, first.id)
assert.equal(state.sessionMessages[first.id]?.some((message) => message.id === 'late-run-assistant'), true)
useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'text', delta: 'late text', sequence: 1 })
useChatStore.getState().applyEvent({ runId: 'late-run', sessionId: first.id, type: 'done', content: 'late text', tools: [], sequence: 2 })
await waitFor('late terminal event to commit', () => {
const assistant = useChatStore.getState().sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant')
return assistant?.content === 'late text' && assistant.status === 'done'
})
state = useChatStore.getState()
assert.equal(state.currentSessionId, second.id)
assert.deepEqual(state.messages, [secondMessage], 'late stream events must not leak into the selected session')
const originalAssistant = state.sessionMessages[first.id]?.find((message) => message.id === 'late-run-assistant')
assert.equal(originalAssistant?.content, 'late text')
assert.equal(originalAssistant?.status, 'done')
}
async function testDraftContextWinsWhileFirstSessionIsCreated(): Promise<void> {
resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low' })
const backend = installFakeBackend([], {})
const createGate = deferred<SessionMeta>()
const updateGate = deferred<SessionMeta>()
let createContext: SessionContext | undefined
let updateCapture: { id: string; patch: SessionContext } | undefined
let sendCalls = 0
backend.api.session.create = async (_workspaceId, context) => {
createContext = context ? { ...context } : undefined
return createGate.promise
}
backend.api.session.updateContext = async (sessionId, patch) => {
updateCapture = { id: sessionId, patch: { ...patch } }
return updateGate.promise
}
backend.api.chat.send = async () => {
sendCalls++
return makeSendResult('run-first', 'draft-b', 'Draft B')
}
const sending = useChatStore.getState().send('first message')
await waitFor('first session creation', () => createContext !== undefined)
assert.deepEqual(createContext, { modelId: 'draft-a', thinkingLevel: 'low' })
await useChatStore.getState().selectModel('draft-b')
await useChatStore.getState().selectThinkingLevel('high')
assert.deepEqual(useChatStore.getState().draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
const created = makeSession('created-session', { modelId: 'draft-a', thinkingLevel: 'low' })
backend.replaceSession(created)
createGate.resolve(created)
await waitFor('draft context patch after session creation', () => updateCapture !== undefined)
assert.deepEqual(updateCapture, {
id: 'created-session',
patch: { modelId: 'draft-b', thinkingLevel: 'high' }
})
assert.equal(sendCalls, 0, 'the first send must wait for the newer draft settings to persist')
const updated = { ...created, ...updateCapture!.patch }
backend.replaceSession(updated)
updateGate.resolve(updated)
await sending
const state = useChatStore.getState()
assert.equal(state.currentSessionId, 'created-session')
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.modelId, 'draft-b')
assert.equal(state.sessions.find((item) => item.id === 'created-session')?.thinkingLevel, 'high')
assert.deepEqual(state.draftContext, { modelId: 'draft-b', thinkingLevel: 'high' })
assert.equal(sendCalls, 1)
}
function testNextRequestSettingsWhitelist(): void {
assert.deepEqual(nextRequestSettings({ modelId: 'model-next', thinkingLevel: 'high' }), {
modelId: 'model-next',
thinkingLevel: 'high'
})
assert.deepEqual(nextRequestSettings({}), {})
const forbidden: SessionContext[] = [
{ permissionMode: 'full-access' },
{ cwd: 'C:\\workspace' },
{ agentMode: 'plan' },
{ projectId: 'project-1' },
{ workspaceId: 'workspace-1' },
{ expertId: 'expert-1' },
{ modelId: 'model-next', permissionMode: 'default-sandbox' }
]
for (const patch of forbidden) {
assert.throws(() => nextRequestSettings(patch), /运行期间只能修改下次发送的模型和思考强度/)
}
assert.throws(() => nextRequestSettings({ modelId: ' ' }), /模型标识无效/)
assert.throws(() => nextRequestSettings({ thinkingLevel: 'invalid' as never }), /无效的思考强度/)
}
const originalFetch = globalThis.fetch
globalThis.fetch = (async () => {
throw new Error('network access is forbidden in request-settings regression')
}) as typeof fetch
const completed: string[] = []
try {
await testSerialModelWritesAndFrozenRunningTurn()
completed.push('serial model writes + frozen active turn')
await testSendFailureDoesNotPoisonRetry()
completed.push('send failure retry')
await testLateResultAndEventStayInOriginalSession()
completed.push('late result/event session isolation')
await testDraftContextWinsWhileFirstSessionIsCreated()
completed.push('draft settings during first session creation')
testNextRequestSettingsWhitelist()
completed.push('nextRequestSettings whitelist')
console.log(JSON.stringify({ result: 'PASS', tests: completed }, null, 2))
} finally {
globalThis.fetch = originalFetch
}
@@ -0,0 +1,26 @@
import { build } from 'esbuild'
import { spawnSync } from 'node:child_process'
const tests = [
'prompt-queue-regression.ts',
'prompt-queue-adversarial-regression.ts',
'input-composition-regression.ts',
'capability-picker-regression.tsx',
'request-settings-regression.ts',
'context-usage-regression.ts',
'claude-capabilities-regression.ts'
]
const directory = '.tmp/composer-capabilities-tests'
await build({
entryPoints: tests.map(file => `scripts/${file}`),
outdir: directory, outExtension: { '.js': '.mjs' }, bundle: true,
platform: 'node', format: 'esm', packages: 'external', jsx: 'automatic',
loader: { '.css': 'empty' }, logLevel: 'warning'
})
for (const file of tests) {
const result = spawnSync(process.execPath, [`${directory}/${file.replace(/\.tsx?$/, '.mjs')}`], {
stdio: 'inherit', windowsHide: true, timeout: 120000
})
if (result.error) throw result.error
if (result.status !== 0) process.exit(result.status ?? 1)
}
@@ -0,0 +1,14 @@
import { build } from 'esbuild'
import { spawnSync } from 'node:child_process'
const test = 'recommended-skills-backend-regression.ts'
const directory = '.tmp/recommended-skills-tests'
await build({
entryPoints: [`scripts/${test}`], outdir: directory, outExtension: { '.js': '.mjs' },
bundle: true, platform: 'node', format: 'esm', packages: 'external', logLevel: 'warning'
})
const result = spawnSync(process.execPath, [`${directory}/${test.replace(/\.tsx?$/, '.mjs')}`], {
stdio: 'inherit', windowsHide: true, timeout: 180000
})
if (result.error) throw result.error
if (result.status !== 0) process.exit(result.status ?? 1)
+2 -1
View File
@@ -121,7 +121,8 @@ async function main(): Promise<void> {
export const ipcMain = {
handle: (name, handler) => state.handlers.set(name, handler)
};
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) };
export const webContents = { getAllWebContents: () => [] };
export const dialog = { showOpenDialog: async () => ({ canceled: true, filePaths: [] }), showSaveDialog: async () => ({ canceled: true }) };
export const shell = { openPath: async () => '' };
export const powerSaveBlocker = { start: () => 1, stop: () => undefined };
export class BrowserWindow {}
+88
View File
@@ -0,0 +1,88 @@
// Real ComposerControls + Slate in isolated Electron. No model, network or user storage.
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
const require = createRequire(import.meta.url), electron = require('electron')
if (typeof electron === 'string') {
const directory = fs.mkdtempSync(path.resolve('.tmp/capability-slash-'))
const env = { ...process.env }; delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = spawn(electron, ['--no-sandbox', path.resolve('scripts/test-capability-slash-ui.mjs'), directory], { env, windowsHide: true, stdio: 'inherit' })
const timer = setTimeout(() => child.kill(), 90000)
process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
} else {
void (async () => {
const { app, BrowserWindow } = electron, directory = path.resolve(process.argv.at(-1))
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('capability-slash-'))
app.setPath('userData', path.join(directory, 'data')); app.disableHardwareAcceleration()
const source = file => JSON.stringify(path.resolve(file).replaceAll('\\', '/'))
fs.writeFileSync(path.join(directory, 'fixture.tsx'), `import React,{useState}from'react';import{createRoot}from'react-dom/client';
import{ComposerControls}from${source('src/renderer/src/workbuddy/composer/ComposerControls')};
import{withCapabilitySelection}from${source('src/renderer/src/workbuddy/composer/capability-selection')};
import${source('src/renderer/src/styles/tokens.css')};import${source('src/renderer/src/styles/app.css')};
window.ycode={resources:{},chat:{}};
function Test(){const[state,set]=useState({text:'',skills:[]}),[version,bump]=useState(0);window.__qa={state,setup:s=>{set(s);bump(v=>v+1)},toggle:()=>set(s=>withCapabilitySelection(s,{id:'archify',name:'archify',kind:'skill'},{source:'menu'}))};return <div className="chat-pane cr-theme" style={{padding:40}}><ComposerControls key={version} value={state.text} userContent={state.userContent} skills={state.skills} attachments={state.attachments??[]} selectedCapabilities={state.skills.map(s=>({id:s.id,kind:'skill'}))} capabilityItems={[{id:'archify',name:'archify',kind:'skill'},{id:'existing',name:'existing',kind:'skill'}]} onChange={text=>set(s=>({...s,text}))} onUserContentChange={userContent=>set(s=>({...s,userContent}))} onSelectCapability={(item,intent)=>set(s=>withCapabilitySelection(s,item,intent))} onRemoveSkill={id=>set(s=>({...s,skills:s.skills.filter(v=>v.id!==id)}))} onSend={()=>{throw Error('Unexpected send')}} /></div>}createRoot(document.getElementById('root')).render(<Test/>);`)
await require('esbuild').build({ entryPoints: [path.join(directory, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic', outfile: path.join(directory, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.jpg': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' }, logLevel: 'warning' })
fs.writeFileSync(path.join(directory, 'index.html'), '<html data-theme="light"><link rel="stylesheet" href="fixture.css"><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
await app.whenReady()
const win = new BrowserWindow({ show: false, width: 1100, height: 850, webPreferences: { offscreen: true, backgroundThrottling: false } }), checks = [], errors = []
win.webContents.on('console-message', event => { if (event.level === 'error') errors.push(event.message) })
const read = code => win.webContents.executeJavaScript(code), pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const wait = async code => { for (let n = 0; n < 150; n++) { if (await read(code)) return; await pause(30) } throw Error('Timeout ' + code) }
const check = (name, actual, expected = true) => { assert.deepEqual(actual, expected, name); checks.push(name) }
const key = async (keyCode, modifiers = []) => { win.webContents.sendInputEvent({ type: 'keyDown', keyCode, modifiers }); win.webContents.sendInputEvent({ type: 'keyUp', keyCode, modifiers }); await pause(60) }
const setup = async state => { await read(`__qa.setup(${JSON.stringify(state)})`); await pause(160); await read('document.querySelector(".wb-composer__input").focus()') }
const choose = async (keyboard = false) => {
await wait('!!document.querySelector(".yc-composer-slash-picker")')
if (keyboard) { await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Enter') }
else await read('[...document.querySelectorAll(".yc-capability-picker button")].find(e=>e.textContent.includes("archify")).click()')
await wait('!document.querySelector(".yc-composer-slash-picker")'); await pause(200)
}
try {
await win.loadFile(path.join(directory, 'index.html')); await wait('!!window.__qa')
await setup({ text: '', skills: [] }); await win.webContents.insertText('/archify'); await choose()
check('mouse selection consumes the textarea slash', await read('__qa.state.text'), '')
check('structured content has no stale slash', await read('__qa.state.userContent.filter(v=>v.type==="text").map(v=>v.text).join("")'), '')
check('one selected skill survives transition into Slate', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
check('the menu stays closed after editor remount', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
check('the real rich editor is focused', await read('!!document.activeElement.closest(".cr-reference-editor")'))
await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await choose(true)
check('Enter consumes a bare slash without sending', await read('__qa.state.text'), '')
await setup({ text: 'left RIGHT', skills: [] })
await read('document.querySelector("textarea").setSelectionRange(5,5)'); await win.webContents.insertText('/archify'); await choose()
check('selection preserves text after the caret', await read('__qa.state.text'), 'left RIGHT')
await setup({ text: 'before ', skills: [{ id: 'existing', name: 'existing' }], userContent: [{ type: 'skill', id: 'existing' }, { type: 'text', text: 'before ' }] })
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose(true)
check('rich text selection preserves the existing skill', await read('__qa.state.skills.map(v=>v.id)'), ['existing', 'archify'])
check('rich text selection preserves surrounding text', await read('__qa.state.text'), 'before ')
check('rich text references survive token deletion', await read('__qa.state.userContent.filter(v=>v.type==="skill").map(v=>v.id).sort()'), ['archify', 'existing'])
await win.webContents.insertText('next'); await wait('__qa.state.text.includes("next")')
check('typing continues at the consumed trigger position', await read('__qa.state.text'), 'before next')
await setup({ text: '', skills: [{ id: 'archify', name: 'archify' }] })
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
check('slash selection of an already selected skill never toggles it off', await read('__qa.state.skills.map(v=>v.id)'), ['archify'])
await read('__qa.toggle()'); await pause(100)
check('the add-menu policy still toggles an existing selection', await read('__qa.state.skills.length'), 0)
await setup({ text: 'first\nsecond ', skills: [], attachments: [{ id: 'fixture-file', name: 'keep.txt', mimeType: 'text/plain', size: 4 }], userContent: [{ type: 'attachment', id: 'fixture-file' }, { type: 'text', text: 'first\nsecond ' }] })
await key('End', ['control']); await win.webContents.insertText('/archify'); await choose()
check('multiline rich text survives slash deletion', await read('__qa.state.text'), 'first\nsecond ')
check('attachment metadata survives slash selection', await read('__qa.state.attachments.map(v=>v.id)'), ['fixture-file'])
check('attachment inline reference survives slash selection', await read('__qa.state.userContent.some(v=>v.type==="attachment"&&v.id==="fixture-file")'))
await setup({ text: '', skills: [] }); await win.webContents.insertText('/'); await wait('!!document.querySelector(".yc-composer-slash-picker")'); await read('document.querySelector(".yc-capability-picker input").focus()'); await key('Escape')
await pause(250); check('Escape closes the menu until the trigger is edited', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
await win.webContents.insertText('a'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
check('editing the dismissed trigger reopens the menu', true)
await setup({ text: '', skills: [] })
await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionstart",{bubbles:true}))')
await win.webContents.insertText('/'); await pause(150)
check('IME composition does not open a slash menu', await read('!!document.querySelector(".yc-composer-slash-picker")'), false)
await read('document.querySelector("textarea").dispatchEvent(new CompositionEvent("compositionend",{bubbles:true}))'); await wait('!!document.querySelector(".yc-composer-slash-picker")')
check('composition completion allows slash detection', true)
check('no renderer errors', errors, [])
fs.writeFileSync(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, errors }, null, 2)); console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory })); app.exit(0)
} catch (error) {
fs.writeFileSync(path.join(directory, 'failure.json'), JSON.stringify({ error: String(error.stack ?? error), checks, errors, state: await read('__qa.state'), focus: await read('({active:document.activeElement.outerHTML,selection:document.getSelection()?.toString(),anchor:document.getSelection()?.anchorNode?.parentElement?.outerHTML})'), html: await read('document.body.innerHTML') }, null, 2)); console.error(error); app.exit(1)
}
})().catch(error => { console.error(error); electron.app.exit(1) })
}
+285
View File
@@ -0,0 +1,285 @@
/** Real production ConnectorsPage in isolated Electron with a controlled MCP host.
* Only the host IPC is faked; the page, styles, icons and helpers are the shipped ones.
* No WorkBuddy runtime or original evidence is accessed by this script. */
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
const require = createRequire(import.meta.url)
const electronModule = require('electron')
const electron = typeof electronModule === 'object' && electronModule.app ? electronModule : null
const root = path.resolve('.'), base = path.join(root, '.tmp')
if (!electron) {
fs.mkdirSync(base, { recursive: true })
const output = fs.mkdtempSync(path.join(base, 'connectors-page-')), env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE; delete env.ELECTRON_RENDERER_URL
const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-connectors-page.mjs'), output], { env, windowsHide: true, stdio: 'inherit' })
const timer = setTimeout(() => child.kill(), 120_000)
process.exitCode = await new Promise(resolve => child.once('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
} else void (async () => {
const { app, BrowserWindow, ipcMain } = electron
const output = path.resolve(process.argv.at(-1))
assert.ok(path.relative(base, output).startsWith('connectors-page-') && !path.relative(base, output).includes('..'))
electron.app.disableHardwareAcceleration()
fs.mkdirSync(path.join(output, 'data'), { recursive: true })
app.setPath('userData', path.join(output, 'data'))
await app.whenReady()
// The controlled host mirrors the production McpApi contract, including the
// approval hash rule and the per-server enable/connect state machine.
const calls = [], servers = new Map()
let failNextEnable = false
const clone = value => JSON.parse(JSON.stringify(value))
const makeServer = (id, name, overrides = {}) => ({
id, name, transport: 'stdio', command: 'node', args: ['server.mjs'], enabled: false,
status: 'disconnected', trusted: true, configHash: `hash-${id}`, tools: [],
envKeys: [], headerKeys: [], ...overrides
})
servers.set('ready', makeServer('ready', 'Ready Server', {
enabled: true, status: 'connected', tools: [
{ name: 'echo', description: 'Echo text', inputSchema: { type: 'object' }, enabled: true },
{ name: 'search', description: 'Find needles', inputSchema: { type: 'object' }, enabled: false }
]
}))
servers.set('untrusted', makeServer('untrusted', 'Unapproved Server', { trusted: false }))
servers.set('failing', makeServer('failing', 'Failing Server', { enabled: true, status: 'error', error: 'MCP 连接失败,请检查命令、地址和运行环境' }))
const host = async (method, args) => {
calls.push([method, ...args])
if (method === 'list') return clone([...servers.values()])
if (method === 'qaFailNextEnable') { failNextEnable = true; return }
const server = servers.get(args[0])
if (!server) throw Error('MCP 服务不存在')
if (method === 'save') {
const input = args[0]
if (!input?.name?.trim()) throw Error('请输入有效的 MCP 名称')
if (input.transport !== 'stdio' && input.transport !== 'http') throw Error('不支持的 MCP 连接类型')
if (input.transport === 'stdio' && !input.command?.trim()) throw Error('请输入有效的 MCP 启动命令')
const previous = input.id ? servers.get(input.id) : undefined
if (input.id && !previous) throw Error('MCP 服务不存在')
if (input.id && input.id !== previous.id) throw Error('MCP 服务不存在')
const next = makeServer(input.id ?? `saved-${servers.size}`, input.name.trim(), {
transport: input.transport, command: input.command, args: input.args ?? [], cwd: input.cwd, url: input.url,
enabled: previous?.enabled ?? false, trusted: false, tools: previous?.tools ?? []
})
servers.set(next.id, next)
return clone(next)
}
if (method === 'trust') {
if (server.configHash !== args[1]) throw Error('MCP 配置已变化,请重新查看并批准')
server.trusted = true
return
}
if (method === 'connect') {
if (!server.enabled) throw Error('MCP 服务已禁用')
if (!server.trusted) throw Error('请先批准当前 MCP 配置')
server.status = 'connected'; delete server.error
return clone(server)
}
if (method === 'disconnect') { server.status = 'disconnected'; return }
if (method === 'setEnabled') {
if (failNextEnable) { failNextEnable = false; throw Error('MCP 服务已禁用') }
if (args[1] && !server.trusted) throw Error('请先批准当前 MCP 配置')
server.enabled = args[1]
server.status = args[1] ? 'connected' : 'disconnected'
return
}
if (method === 'setToolEnabled') {
const tool = server.tools.find(item => item.name === args[1])
if (!tool) throw Error('MCP 工具不存在')
tool.enabled = args[2]
return
}
if (method === 'remove') { servers.delete(server.id); return }
throw Error('Unknown QA method ' + method)
}
ipcMain.handle('mcp-qa', async (_event, method, args) => host(method, args))
ipcMain.handle('resources-qa', async () => undefined)
fs.writeFileSync(path.join(output, 'preload.cjs'),
`const{contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('ycode',{mcp:Object.fromEntries(['list','save','trust','connect','disconnect','setEnabled','setToolEnabled','remove','qaFailNextEnable'].map(method=>[method,(...args)=>ipcRenderer.invoke('mcp-qa',method,args)])),resources:{openExternal:async()=>undefined}});`)
const location = file => JSON.stringify(path.join(root, file).replaceAll('\\', '/'))
fs.writeFileSync(path.join(output, 'fixture.tsx'), `
import React from 'react'
import { createRoot } from 'react-dom/client'
import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')}
import ${location('src/renderer/src/styles/tokens.css')}
import ${location('src/renderer/src/styles/app.css')}
createRoot(document.getElementById('root')!).render(<div className="chat-pane cr-theme" style={{ height: '100vh' }}><Center initialTab="connectors" onBackToChat={() => {}} /></div>)
`)
await require('esbuild').build({
entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
outfile: path.join(output, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' },
logLevel: 'warning'
})
fs.writeFileSync(path.join(output, 'index.html'),
'<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:100%;height:100%;margin:0}</style></head><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
const win = new BrowserWindow({
width: 1052, height: 901, useContentSize: true, frame: false, show: false,
webPreferences: { preload: path.join(output, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }
})
const errors = [], checks = []
win.webContents.on('console-message', (_event, level, message) => { if (level === 3 || level === 'error') errors.push(String(message)) })
const read = code => win.webContents.executeJavaScript(code)
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const check = (name, actual, expected) => {
if (typeof expected === 'function') assert.ok(expected(actual), name + ': ' + JSON.stringify(actual))
else assert.deepEqual(actual, expected, name)
checks.push(name)
}
const wait = async code => {
for (let index = 0; index < 200; index++) { if (await read(code)) return; await pause(50) }
throw Error('Timeout ' + code)
}
const click = async selector => {
const point = await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});const r=e.getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2}})()`)
win.webContents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })
await pause(120)
}
const clickText = async (selector, label) => {
const match = await read(`(()=>{const e=[...document.querySelectorAll(${JSON.stringify(selector)})].find(e=>e.textContent.trim()===${JSON.stringify(label)});if(!e)throw Error('Missing text '+${JSON.stringify(label)});e.dataset.qaTarget='true';return '[data-qa-target="true"]'})()`)
await click(match)
await read(`document.querySelector(${JSON.stringify(match)})?.removeAttribute('data-qa-target')`)
}
const capture = async name => {
win.webContents.invalidate()
// A hidden window keeps the previous frame, so warm the paint first, give
// the compositor a frame, and keep the second capture.
await win.webContents.capturePage()
await pause(220)
const image = await win.webContents.capturePage()
fs.writeFileSync(path.join(output, name + '.png'), image.toPNG())
return !image.isEmpty()
}
try {
await win.loadFile(path.join(output, 'index.html'))
// An inactive window still composites real frames; a hidden one does not.
win.showInactive()
await wait('!!document.querySelector(".ycode-connectors")')
check('the maintenance placeholder is gone', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
check('the page renders the real connector list', await read('document.querySelectorAll(".connector-card").length'), 3)
check('the connector card names come from the host', await read('[...document.querySelectorAll(".connector-card-name")].map(e=>e.textContent)'), ['Ready Server', 'Unapproved Server', 'Failing Server'])
check('the toolbar keeps the shared search and the custom connector action', await read('[!!document.querySelector(".connectors-toolbar .ec-search-wrapper"), !!document.querySelector(".connector-topbar-action-btn")]'), [true, true])
await capture('connector-list')
check('the detail overlay opens for a selected connector', await (async () => {
await click('.connector-card')
await wait('!!document.querySelector(".connector-detail-modal")')
return read('document.querySelector(".connector-detail-name").textContent')
})(), 'Ready Server')
check('the detail overlay lists the real tool count', await read('document.querySelector(".connector-detail-desc").textContent'), '已连接 · 2 个工具')
await capture('connector-detail')
await click('.connector-detail-close')
check('the detail overlay closes', await read('!!document.querySelector(".connector-detail-modal")'), false)
await click('.connector-topbar-action-btn')
await wait('!!document.querySelector(".mcp-modal")')
check('the management modal opens with the real server list', await read('document.querySelectorAll(".mcp-item").length'), 3)
check('the panel reports the enabled count from the host', await read('document.querySelector(".mcp-panel-status-summary").textContent'), '2 启用 · 1 失败')
check('an unapproved server shows the approval notice', await read('!!document.querySelector(".mcp-item-approval")'), true)
check('an unapproved server cannot be enabled by the toggle', await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Unapproved Server'));return item.querySelector('.mcp-toggle').disabled})()`), true)
await capture('mcp-panel')
await clickText('.mcp-item-approval-btn', '批准配置')
await wait(`!document.querySelector('.mcp-item-approval')`)
check('approving the configuration reaches the host', calls.some(call => call[0] === 'trust' && call[1] === 'untrusted'), true)
check('approving also enables the approved server', await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Unapproved Server'));return item.querySelector('.mcp-toggle').getAttribute('aria-checked')})()`), 'true')
await click('.mcp-item-header')
await wait('!!document.querySelector(".mcp-item-tools")')
check('the expanded server lists its real tools', await read('[...document.querySelectorAll(".mcp-item-tool")].map(e=>e.textContent)'), ['echo', 'search'])
await clickText('.mcp-item-tool', 'search')
await wait(`!document.querySelector('.mcp-item-tool')?.classList.contains('mcp-item-tool--disabled')`)
check('toggling a tool reaches the host', calls.some(call => call[0] === 'setToolEnabled' && call[1] === 'ready' && call[2] === 'search'), true)
check('a failed server shows the real host error', await read('!!document.querySelector(".mcp-item-error")'), true)
await click('.mcp-item-error-link')
await wait(`!document.querySelector('.mcp-item-error')`)
check('the retry action reconnects the failed server', calls.some(call => call[0] === 'connect' && call[1] === 'failing'), true)
await clickText('.mcp-item-action', '编辑')
await wait('!!document.querySelector(".mcp-config-editor")')
check('the config editor opens with the real server document', await read(`(()=>{const ta=document.querySelector('.mcp-config-textarea');const editor=document.querySelector('.monaco-editor');return ta?ta.value:(document.querySelector('.mcp-config-editor-monaco-wrapper')?editor?'monaco':'loading':'')})()`),
value => value === 'loading' || value === 'monaco' || JSON.parse(value).mcpServers.ReadyServer)
await capture('mcp-editor')
await clickText('.mcp-config-editor-header-actions button', '取消')
await wait('!document.querySelector(".mcp-config-editor")')
await clickText('.mcp-modal-header-right button', '添加 MCP')
await wait('!!document.querySelector(".mcp-config-editor")')
check('the add editor starts from an empty stdio document', await read(`(()=>{const ta=document.querySelector('.mcp-config-textarea');if(ta)return JSON.parse(ta.value).mcpServers;const w=document.querySelector('.mcp-config-editor-monaco-wrapper');return w?'loading-or-monaco':''})()`),
value => value === 'loading-or-monaco' || !!value['my-mcp'])
await clickText('.mcp-config-editor-header-actions button', '取消')
await wait('!document.querySelector(".mcp-config-editor")')
// The card action opens the modal with exactly that server expanded.
await click('.mcp-modal-close')
await wait('!document.querySelector(".mcp-modal")')
await read(`(()=>{const card=[...document.querySelectorAll('.connector-card')].find(e=>e.textContent.includes('Failing Server'));card.querySelector('.connector-connect-btn').click();return true})()`)
await wait('!!document.querySelector(".mcp-modal")')
await wait(`!![...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Failing Server'))?.querySelector('.mcp-item-action[aria-label="删除 Failing Server"]')`)
await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Failing Server'));item.querySelector('.mcp-item-action[aria-label="删除 Failing Server"]').click();return true})()`)
await wait('!!document.querySelector(".yc-mcp-confirm")')
check('the delete confirmation names the real server', await read('document.querySelector(".yc-mcp-confirm").textContent.includes("Failing Server")'), true)
await clickText('.yc-mcp-confirm button', '取消')
await wait('!document.querySelector(".yc-mcp-confirm")')
check('cancelling the delete keeps the server', await read('document.querySelectorAll(".mcp-item").length'), 3)
const setSearch = async value => {
await read(`(()=>{const e=document.querySelector('.cb-search-input__input');if(!e)throw Error('missing MCP search input');
const setter=Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set;setter.call(e,${JSON.stringify(value)});
e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`)
await pause(200)
}
await setSearch('fail')
await pause(200)
check('the management search filters the real list', await read('document.querySelectorAll(".mcp-item").length'), 1)
await setSearch('')
await click('.mcp-modal-close')
await wait('!document.querySelector(".mcp-modal")')
check('closing the management modal returns to the card list', await read('document.querySelectorAll(".connector-card").length'), 3)
// A rejected host call must stay visible inside the management modal.
await read(`window.ycode.mcp.qaFailNextEnable()`)
await read(`(()=>{document.querySelector('.connector-topbar-action-btn').click();return true})()`)
await wait('!!document.querySelector(".mcp-modal")')
await wait(`!![...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Ready Server'))`)
await read(`(()=>{const item=[...document.querySelectorAll('.mcp-item')].find(e=>e.textContent.includes('Ready Server'));item.querySelector('.mcp-toggle').click();return true})()`)
await wait('!!document.querySelector(".mcp-modal [role=alert]")')
check('a rejected action reports the real host error inside the modal',
await read('document.querySelector(".mcp-modal [role=alert]").textContent'),
value => typeof value === 'string' && value.includes('MCP 服务已禁用'))
check('the error banner is not only behind the modal overlay',
await read(`(()=>{const el=document.querySelector('.mcp-modal [role=alert]');const r=el.getBoundingClientRect();return r.width>0&&r.height>0})()`), true)
check('the error banner is painted inside the visible modal body',
await read(`(()=>{const el=document.querySelector('.mcp-modal [role=alert]');const r=el.getBoundingClientRect();
const body=document.querySelector('.mcp-modal-body').getBoundingClientRect();
const s=getComputedStyle(el);
return {text:el.textContent,top:Math.round(r.top),bottom:Math.round(r.bottom),bodyTop:Math.round(body.top),
bodyBottom:Math.round(body.bottom),color:s.color,background:s.backgroundColor,visible:s.visibility,opacity:s.opacity}})()`),
value => value.text.includes('MCP 服务已禁用') && value.top >= value.bodyTop && value.bottom <= value.bodyBottom + 1 &&
value.color !== 'rgba(0, 0, 0, 0)' && value.background !== 'rgba(0, 0, 0, 0)' && value.visible === 'visible' && value.opacity === '1')
await capture('mcp-error')
check('no renderer errors', errors, [])
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({
result: 'PASS', checks,
calls,
limits: 'Controlled MCP host in isolated Electron; the shipped page, helpers, icons and styles are used. Not WorkBuddy pixel acceptance and not a real MCP server connection.'
}, null, 2))
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
win.destroy(); app.exit(0)
} catch (error) {
await capture('failure')
fs.writeFileSync(path.join(output, 'failure.json'), JSON.stringify({ error: String(error), checks, errors, calls }, null, 2))
console.error(error)
win.destroy(); app.exit(1)
}
})().catch(error => { console.error(error); electron.app.exit(1) })
@@ -0,0 +1,223 @@
// Real Archify package audit for the recommended-skill installer.
// Downloads the pinned archive, verifies its digest, audits the ZIP structure and
// metadata, then imports it through the production skills module against an
// isolated Electron userData directory. No package script is ever executed.
import assert from 'node:assert/strict'
import fs from 'node:fs/promises'
import fsSync from 'node:fs'
import path from 'node:path'
import crypto from 'node:crypto'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import { unzipSync } from 'fflate'
const COMMIT = '25cf37080fc8b1dbe758a4efc3935b55f3327083'
const REPOSITORY = 'tt-a1i/archify'
const PACKAGE_PATH = 'archify.zip'
const EXPECTED_SHA256 = '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5'
const EXPECTED_BYTES = 5271959
const DOWNLOAD_URL = `https://api.github.com/repos/${REPOSITORY}/contents/${PACKAGE_PATH}?ref=${COMMIT}`
const EXPECTED_SKILL_NAME = 'archify'
const EXPECTED_SKILL_VERSION = '3.2'
const EXPECTED_PACKAGE_VERSION = '3.2.0'
const EXPECTED_LICENSE = 'MIT'
const EXPECTED_FILE_COUNT = 200
const EXPECTED_EXPANDED_BYTES = 17812933
const repository = path.resolve('.')
const testRoot = await fs.mkdtemp(path.resolve('.tmp/archify-package-'))
const relative = path.relative(path.resolve('.tmp'), testRoot)
assert.ok(relative.startsWith('archify-package-') && !relative.includes('..') && !path.isAbsolute(relative))
const userData = path.join(testRoot, 'data')
const workspace = path.join(testRoot, 'workspace')
await Promise.all([fs.mkdir(userData, { recursive: true }), fs.mkdir(workspace, { recursive: true })])
const checks = []
const check = (name, actual, expected) => {
assert.deepEqual(actual, expected, name)
checks.push(name)
}
const finding = (name, detail) => checks.push(`${name} :: ${detail}`)
const note = (name, detail) => checks.push(`${name} :: ${detail}`)
async function download() {
const local = process.env.YCODE_ARCHIFY_ZIP
if (local) {
const bytes = await fs.readFile(path.resolve(local))
note('archive source', `local file ${path.resolve(local)}`)
return bytes
}
const response = await fetch(DOWNLOAD_URL, { redirect: 'error', signal: AbortSignal.timeout(120000),
headers: { Accept: 'application/vnd.github.raw+json', 'X-GitHub-Api-Version': '2022-11-28' } })
assert.equal(response.status, 200, 'pinned archive download must succeed')
const bytes = Buffer.from(await response.arrayBuffer())
note('archive source', DOWNLOAD_URL)
return bytes
}
const bytes = await download()
const archivePath = path.join(testRoot, PACKAGE_PATH)
await fs.writeFile(archivePath, bytes)
check('pinned archive byte length matches the audited package', bytes.length, EXPECTED_BYTES)
const actualSha256 = crypto.createHash('sha256').update(bytes).digest('hex')
check('pinned archive digest matches the audited package', actualSha256, EXPECTED_SHA256)
check('archive is served from the pinned commit URL', DOWNLOAD_URL.includes(COMMIT), true)
const entries = unzipSync(bytes)
const names = Object.keys(entries)
const expandedBytes = names.reduce((sum, name) => sum + entries[name].length, 0)
check('archive contains the audited file count', names.length, EXPECTED_FILE_COUNT)
check('archive expands to the audited size', expandedBytes, EXPECTED_EXPANDED_BYTES)
check('every archive entry lives under a single skill directory',
names.every(name => name.startsWith('archify/')), true)
check('archive has exactly one SKILL.md at the package root',
names.filter(name => name === 'archify/SKILL.md').length, 1)
check('archive contains no nested second skill',
names.filter(name => name.split('/').filter(part => part === 'SKILL.md').length > 1).length, 0)
const text = (name) => Buffer.from(entries[name]).toString('utf8')
const skillMarkdown = text('archify/SKILL.md')
const frontMatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(skillMarkdown)
assert.ok(frontMatter, 'SKILL.md must carry YAML front matter')
const skillMeta = frontMatter[1]
check('SKILL.md declares the audited skill name', /(^|\n)name:\s*archify\s*(\n|$)/.test(skillMeta), true)
check('SKILL.md declares the audited license', /(^|\n)license:\s*MIT\s*(\n|$)/.test(skillMeta), true)
check('SKILL.md declares the audited metadata version', /version:\s*"?3\.2"?/.test(skillMeta), true)
check('SKILL.md declares the audited author', /author:\s*tt-a1i/.test(skillMeta), true)
const packageJson = JSON.parse(text('archify/package.json'))
check('package.json declares the audited package name', packageJson.name, EXPECTED_SKILL_NAME)
check('package.json declares the audited package version', packageJson.version, EXPECTED_PACKAGE_VERSION)
check('package.json declares the audited license', packageJson.license, EXPECTED_LICENSE)
check('package.json keeps the package private', packageJson.private, true)
const release = JSON.parse(text('archify/skill-release.json'))
check('release manifest declares the audited version', release.version, EXPECTED_PACKAGE_VERSION)
check('release manifest points at the audited repository', release.source.repository, `https://github.com/${REPOSITORY}`)
check('release manifest exposes an update URL that the host must not auto-follow',
typeof release.updateManifestUrl === 'string' && release.updateManifestUrl.length > 0, true)
check('archive ships the MIT license text', text('archify/LICENSE').includes('MIT License'), true)
check('archive ships the third-party notices', names.includes('archify/THIRD_PARTY_NOTICES.md'), true)
const scriptEntries = names.filter(name => /\.(mjs|cjs|js|ts|sh|ps1|bat|cmd)$/i.test(name))
check('archive ships executable helper scripts as inert files', scriptEntries.length > 0, true)
note('script entries (not executed)', `${scriptEntries.length} files, e.g. ${scriptEntries.slice(0, 3).join(', ')}`)
// Import through the production skills module. Only Electron's userData path is
// redirected; every other code path is the shipped implementation.
const bundled = path.join(testRoot, 'skills-archify.mjs')
await build({
entryPoints: [path.resolve('src/main/capabilities/skills.ts')],
bundle: true,
platform: 'node',
format: 'esm',
outfile: bundled,
logLevel: 'warning',
banner: { js: "import { createRequire as createArchifyTestRequire } from 'node:module';const require=createArchifyTestRequire(import.meta.url);" },
plugins: [{
name: 'isolated-electron-path',
setup(context) {
context.onResolve({ filter: /^electron$/ }, () => ({ path: 'electron', namespace: 'archify-test' }))
context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
contents: `export const app={getPath:(name)=>{if(name!=='userData')throw Error('Unexpected app path');return ${JSON.stringify(userData)}}};export const safeStorage={isEncryptionAvailable:()=>false,encryptString:(v)=>Buffer.from(String(v),'utf8'),decryptString:(v)=>Buffer.from(v).toString('utf8')}`
}))
}
}, {
name: 'record-child-process',
setup(context) {
context.onResolve({ filter: /^(node:)?child_process$/ }, () => ({ path: 'child-process', namespace: 'archify-test' }))
context.onLoad({ filter: /.*/, namespace: 'archify-test' }, () => ({
contents: `const record=(name)=>globalThis.__archifyChildProcesses.push(name);export const spawn=(...a)=>{record('spawn');throw Error('child process blocked in package audit')};export const spawnSync=(...a)=>{record('spawnSync');throw Error('child process blocked in package audit')};export const exec=(...a)=>{record('exec');throw Error('child process blocked in package audit')};export const execSync=(...a)=>{record('execSync');throw Error('child process blocked in package audit')};export const fork=(...a)=>{record('fork');throw Error('child process blocked in package audit')};export default{spawn,spawnSync,exec,execSync,fork};`
}))
}
}]
})
globalThis.__archifyChildProcesses = []
const bundleSource = await fs.readFile(bundled, 'utf8')
check('the production skills module does not spawn a child process during import',
globalThis.__archifyChildProcesses.length, 0)
const api = await import(pathToFileURL(bundled).href)
check('the isolated skill library starts empty', await api.listSkills(), [])
const imported = await api.importSkill(archivePath)
check('the pinned archive imports as the audited skill name', imported.name, EXPECTED_SKILL_NAME)
check('the imported skill is a user-scoped import', imported.scope, 'user')
check('the imported skill is enabled by default', imported.enabled, true)
check('the imported skill is not flagged as locally modified', imported.userModified, false)
// Archify nests its version under `metadata:` in the YAML front matter. The host
// parser only reads a top-level `version`, so record what the host can see and
// keep the package manifest as the authoritative version for validation.
finding('host-visible skill version', `imported.version=${String(imported.version)} (front matter nests version under metadata:; expected ${EXPECTED_SKILL_VERSION})`)
check('the package manifest carries the authoritative version', packageJson.version, EXPECTED_PACKAGE_VERSION)
check('the imported skill keeps the audited license text in its body',
(await api.readSkill(imported.id)).content.includes('license: MIT'), true)
const installedPath = path.resolve(imported.path)
check('the installed package stays inside the isolated userData directory',
installedPath.startsWith(path.resolve(userData) + path.sep), true)
check('the installed package never lands in the real user skill library',
installedPath.includes(path.join('capabilities', 'skills', 'packages')), true)
const document = await api.readSkill(imported.id)
check('the imported skill exposes every audited file', document.files.length, EXPECTED_FILE_COUNT)
// The host strips the single enclosing directory, so installed paths are
// relative to the skill root rather than to the archive root.
const installedNames = names.map(name => name.replace(/^archify\//, ''))
check('the imported file list is relative to the skill root',
document.files.every(name => !name.startsWith('archify/')), true)
check('the imported file list is sorted and complete',
document.files.join('|') === installedNames.slice().sort().join('|'), true)
check('the imported skill body is the audited SKILL.md',
document.content === skillMarkdown, true)
const listed = (await api.listSkills(workspace)).find(item => item.id === imported.id)
check('the installed skill is listed for the workspace', listed?.name, EXPECTED_SKILL_NAME)
check('the installed skill stays unmodified after import', listed?.userModified, false)
const reference = await api.readSkillResource(imported.id, 'references/repository-authoring.md', workspace)
check('a shipped reference resource is readable through the host', reference.length > 0, true)
await assert.rejects(api.readSkillResource(imported.id, '../escape.md', workspace), /越界|非法|不安全/)
checks.push('resource reads reject traversal outside the package')
const context = await api.buildSkillContext([imported.id], workspace)
check('the explicit skill context carries the real instructions', context.includes('<skill id='), true)
check('the explicit skill context lists the package resources', context.includes('bin/archify.mjs'), true)
// Nothing in the package may run during installation.
const workspaceEntries = await fs.readdir(workspace)
check('importing the package creates no workspace output', workspaceEntries, [])
check('importing the package creates no generated artifacts directory',
fsSync.existsSync(path.join(workspace, '.archify')), false)
const exampleFiles = []
const collectExamples = async (folder, prefix) => {
for (const item of await fs.readdir(folder, { withFileTypes: true })) {
if (item.isDirectory()) await collectExamples(path.join(folder, item.name), `${prefix}${item.name}/`)
else exampleFiles.push(`${prefix}${item.name}`)
}
}
await collectExamples(path.join(installedPath, 'examples'), '')
check('importing the package leaves the shipped examples untouched',
exampleFiles.length, names.filter(name => name.startsWith('archify/examples/')).length)
await fs.writeFile(path.join(testRoot, 'report.json'), JSON.stringify({
result: 'PASS',
scope: 'Real pinned GitHub archive; digest and ZIP structure audited; import through the production skills module against an isolated Electron userData directory. No package script is executed.',
source: { repository: REPOSITORY, commit: COMMIT, packagePath: PACKAGE_PATH, downloadUrl: DOWNLOAD_URL },
archive: { bytes: bytes.length, sha256: actualSha256, fileCount: names.length, expandedBytes },
metadata: {
skillName: EXPECTED_SKILL_NAME,
skillVersion: imported.version,
packageVersion: packageJson.version,
license: packageJson.license,
author: 'tt-a1i',
releaseVersion: release.version
},
checks,
limitations: [
'The digest and metadata confirm the bytes match the audited package; they are not a security audit of the skill code.',
'Package scripts are shipped as inert files and were never executed, so runtime dependencies and rendering behavior remain unverified.',
'The update manifest URL inside the package is reported only; the host must not auto-follow it.'
]
}, null, 2))
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, directory: testRoot, sha256: actualSha256 }))
+382
View File
@@ -0,0 +1,382 @@
/** Isolated Electron fixture for the recommended-skill page.
* The host is fully controlled: no network, no real skill storage, no user data. */
import fs from 'node:fs'
import path from 'node:path'
import assert from 'node:assert/strict'
import { createRequire } from 'node:module'
import { spawn } from 'node:child_process'
const require = createRequire(import.meta.url)
const electronModule = require('electron')
const electron = typeof electronModule === 'object' && electronModule.app ? electronModule : null
const repository = path.resolve('.'), base = path.join(repository, '.tmp')
if (!electron) {
fs.mkdirSync(base, { recursive: true })
const output = fs.mkdtempSync(path.join(base, 'recommended-skills-ui-'))
const env = { ...process.env }
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = spawn(electronModule, ['--no-sandbox', '--disable-gpu-sandbox', path.resolve('scripts/test-recommended-skills-ui.mjs'), output], { stdio: 'inherit', windowsHide: true, env })
const timer = setTimeout(() => child.kill(), 120000)
process.exitCode = await new Promise(resolve => child.on('exit', code => { clearTimeout(timer); resolve(code ?? 1) }))
} else {
void (async () => {
const { app, BrowserWindow, ipcMain } = electron
const output = path.resolve(process.argv.at(-1)), relative = path.relative(base, output)
assert.ok(relative.startsWith('recommended-skills-ui-') && !relative.includes('..') && !path.isAbsolute(relative))
const userData = path.join(output, 'data')
fs.mkdirSync(userData, { recursive: true })
app.setPath('userData', userData)
const checks = [], errors = [], calls = [], screenshots = []
const recommendation = {
id: 'archify',
name: 'Archify',
description: '生成可交互的架构图、流程图和一页式说明',
author: 'tt-a1i',
license: 'MIT',
repository: 'tt-a1i/archify',
version: '3.2.0',
commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083',
packagePath: 'archify.zip',
sha256: '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5',
expectedBytes: 5271959,
containsScripts: true
}
const localSkill = {
id: 'user:local', name: 'Archify', description: '本地导入的同名技能', version: '1.0.0',
path: 'C:/local/archify', scope: 'user', enabled: true, userInvocable: true,
disableModelInvocation: false, source: 'imported', userModified: true
}
const installedRecommendation = {
id: 'user:rec', name: 'Archify', description: recommendation.description, version: '3.2.0',
path: 'C:/managed/archify', scope: 'user', enabled: true, userInvocable: true,
disableModelInvocation: false, source: 'imported', userModified: false,
origin: {
recommendationId: 'archify', repository: recommendation.repository, commit: recommendation.commit,
packagePath: recommendation.packagePath, archiveSha256: recommendation.sha256, version: '3.2.0', installedAt: 1
}
}
let installed = [], progress = null, failNext = false, cancelRequested = false, delay = 0, recommendationOverride = null, recommendationFails = false
let clock = 0
const listeners = new Set(), changedListeners = new Set()
const emit = state => {
// The host stamps a monotonic time; a strict comparison rejects a stale frame.
const stamped = { ...state, updatedAt: ++clock }
progress = stamped
for (const listener of listeners) listener(stamped)
for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:install-progress', stamped)
}
const changed = () => {
for (const listener of changedListeners) listener()
for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:changed')
}
ipcMain.handle('skills-qa', async (_event, method, args) => {
calls.push([method, ...args])
if (method === 'recommendations') {
if (recommendationFails) throw Error('推荐目录服务不可用')
return recommendationOverride ?? [recommendation]
}
if (method === 'list') return installed
if (method === 'setEnabled') { installed = installed.map(skill => skill.id === args[0] ? { ...skill, enabled: args[1] } : skill); changed(); return }
if (method === 'installState') return progress
// The host only accepts the cancel request; the terminal frame still comes from the install flow.
if (method === 'cancelInstall') { cancelRequested = true; return { cancelled: true } }
if (method === 'installRecommended') {
const [id, options] = args
if (id !== recommendation.id) throw Error('未知推荐技能')
if (failNext) {
failNext = false
emit({ operationId: options.operationId, recommendationId: id, phase: 'failed', receivedBytes: 0, error: '下载失败:网络错误', updatedAt: Date.now() })
throw Error('下载失败:网络错误')
}
emit({ operationId: options.operationId, recommendationId: id, phase: 'downloading', receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
await new Promise(resolve => setTimeout(resolve, delay))
if (cancelRequested) { cancelRequested = false; emit({ operationId: options.operationId, recommendationId: id, phase: 'cancelled', receivedBytes: 0, updatedAt: Date.now() }); throw Object.assign(Error('推荐技能安装已取消'), { name: 'AbortError' }) }
emit({ operationId: options.operationId, recommendationId: id, phase: 'validating', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
await new Promise(resolve => setTimeout(resolve, delay))
if (cancelRequested) { cancelRequested = false; emit({ operationId: options.operationId, recommendationId: id, phase: 'cancelled', receivedBytes: recommendation.expectedBytes, updatedAt: Date.now() }); throw Object.assign(Error('推荐技能安装已取消'), { name: 'AbortError' }) }
emit({ operationId: options.operationId, recommendationId: id, phase: 'installing', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
await new Promise(resolve => setTimeout(resolve, delay))
installed = [installedRecommendation]
emit({ operationId: options.operationId, recommendationId: id, phase: 'installed', receivedBytes: recommendation.expectedBytes, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
changed()
return installedRecommendation
}
if (method === 'qaSetInstalled') { installed = args[0]; changed(); return installed }
if (method === 'qaFailNext') { failNext = true; return true }
if (method === 'qaDelay') { delay = args[0]; return true }
if (method === 'qaSetRecommendations') { recommendationOverride = args[0]; return true }
if (method === 'qaFailRecommendations') { recommendationFails = args[0] === true; return true }
// Simulate an operation owned by another window.
if (method === 'qaEmitForeign') {
const [phase, operationId] = args
emit({ operationId, recommendationId: recommendation.id, phase, receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt: Date.now() })
return true
}
// Replay a frame with an older timestamp to prove the terminal state never regresses.
if (method === 'qaEmitStale') {
const [phase, operationId, updatedAt] = args
const stamped = { operationId, recommendationId: recommendation.id, phase, receivedBytes: 0, totalBytes: recommendation.expectedBytes, updatedAt }
progress = stamped
for (const listener of listeners) listener(stamped)
for (const win of BrowserWindow.getAllWindows()) if (!win.isDestroyed()) win.webContents.send('skills:install-progress', stamped)
return true
}
if (method === 'qaSubscribe') { listeners.add(args[0]); return true }
if (method === 'qaSubscribeChanged') { changedListeners.add(args[0]); return true }
throw Error('Unknown QA method ' + method)
})
fs.writeFileSync(path.join(output, 'preload.cjs'), `const{contextBridge,ipcRenderer}=require('electron');
const invoke=(method,...args)=>ipcRenderer.invoke('skills-qa',method,args);
contextBridge.exposeInMainWorld('ycode',{skills:{
list:(cwd)=>invoke('list',cwd),
recommendations:()=>invoke('recommendations'),
installRecommended:(id,options)=>invoke('installRecommended',id,options),
cancelInstall:(operationId)=>invoke('cancelInstall',operationId),
installState:()=>invoke('installState'),
onInstallProgress:(listener)=>{const wrapped=(_event,state)=>listener(state);ipcRenderer.on('skills:install-progress',wrapped);return()=>ipcRenderer.removeListener('skills:install-progress',wrapped)},
onChanged:(listener)=>{const wrapped=()=>listener();ipcRenderer.on('skills:changed',wrapped);return()=>ipcRenderer.removeListener('skills:changed',wrapped)},
read:async()=>({skill:{},content:'',files:[]}),
setEnabled:(...args)=>invoke('setEnabled',...args), remove:async()=>{}, export:async()=>null, importPath:async()=>null
,qaSetInstalled:(v)=>invoke('qaSetInstalled',v),
qaFailNext:()=>invoke('qaFailNext'),
qaDelay:(v)=>invoke('qaDelay',v),
qaSetRecommendations:(v)=>invoke('qaSetRecommendations',v),
qaFailRecommendations:(v)=>invoke('qaFailRecommendations',v),
qaEmitForeign:(phase,operationId)=>invoke('qaEmitForeign',phase,operationId),
qaEmitStale:(phase,operationId,updatedAt)=>invoke('qaEmitStale',phase,operationId,updatedAt)
},resources:{openExternal:async()=>undefined}});`)
const location = file => JSON.stringify(path.join(repository, file).replaceAll('\\', '/'))
fs.writeFileSync(path.join(output, 'fixture.tsx'), `import React from 'react';
import{createRoot}from'react-dom/client';
import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')};
import${location('src/renderer/src/styles/tokens.css')};
import${location('src/renderer/src/styles/app.css')};
window.__qa={uses:[],created:0,find:0};
createRoot(document.getElementById('root')).render(
<div className="chat-pane cr-theme" style={{height:'100vh'}}>
<Center initialTab="skills" onBackToChat={()=>{}} onUseSkill={skill=>window.__qa.uses.push(skill)} onCreateSkill={()=>window.__qa.created++} onFindSkill={()=>window.__qa.find++}/>
</div>);`)
await require('esbuild').build({
entryPoints: [path.join(output, 'fixture.tsx')], bundle: true, platform: 'browser', format: 'esm', jsx: 'automatic',
outfile: path.join(output, 'fixture.js'), loader: { '.svg': 'file', '.png': 'file', '.jpg': 'file', '.woff': 'file', '.woff2': 'file', '.ttf': 'file' }, logLevel: 'warning'
})
fs.writeFileSync(path.join(output, 'index.html'), '<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:100%;height:100%;margin:0}</style></head><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
const win = new BrowserWindow({
width: 1052, height: 901, useContentSize: true, frame: false, show: false,
webPreferences: { preload: path.join(output, 'preload.cjs'), contextIsolation: true, nodeIntegration: false, backgroundThrottling: false }
})
win.webContents.on('console-message', (_event, level, message) => { if (level === 3) errors.push(message) })
const read = code => win.webContents.executeJavaScript(code)
const check = (label, actual, expected) => {
const pass = typeof expected === 'function' ? expected(actual) : JSON.stringify(actual) === JSON.stringify(expected)
assert.ok(pass, `${label}: ${JSON.stringify(actual)}`)
checks.push(label)
}
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const wait = async code => { for (let index = 0; index < 200; index++) { if (await read(code)) return; await pause(50) } throw Error('Timeout ' + code) }
const click = async selector => {
await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing '+${JSON.stringify(selector)});e.click();return true})()`)
await pause(140)
}
const capture = async name => {
win.webContents.invalidate()
await pause(120)
// A hidden window can capture the previous frame; warm the compositor first.
await win.webContents.capturePage()
await pause(200)
const image = await win.webContents.capturePage()
fs.writeFileSync(path.join(output, name + '.png'), image.toPNG())
screenshots.push(name + '.png')
}
try {
await win.loadFile(path.join(output, 'index.html'))
await wait('!!document.querySelector(".yc-skills-page")')
await wait('!!document.querySelector("[data-recommendation-id=archify]")')
check('the recommendation card renders from the host catalog', await read('document.querySelector("[data-recommendation-id=archify] .sm-card-name")?.textContent'), 'Archify')
check('the card exposes its install state for the host test', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'none')
check('the card shows the author supplied description', await read('document.querySelector("[data-recommendation-id=archify] .sm-card-desc")?.textContent'), recommendation.description)
check('no maintenance placeholder is shown', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
check('the remote catalog tabs still report no connection', await read('!!document.querySelector(".yc-skill-market-unavailable")'), false)
await capture('recommendation-card')
// First install asks for an explicit confirmation.
await click('[aria-label="安装 Archify"]')
await wait('!!document.querySelector(".yc-skill-detail-shell")')
check('the confirm dialog names the source repository', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("tt-a1i/archify")'), true)
check('the confirm dialog states that no install script runs', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("不会执行仓库中的任何安装命令")'), true)
check('the confirm dialog warns that the skill contains scripts', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("包含脚本") || document.querySelector(".yc-skill-detail-shell").textContent.includes("脚本")'), true)
await capture('install-confirm')
await read('(()=>{const first=document.querySelector("[role=dialog] .sm-skill-detail-close");first.focus();first.dispatchEvent(new KeyboardEvent("keydown",{key:"Tab",shiftKey:true,bubbles:true,cancelable:true}))})()')
check('reverse Tab reaches the collapsible source information', await read('document.activeElement.tagName'), 'SUMMARY')
await read('document.activeElement.dispatchEvent(new KeyboardEvent("keydown",{key:"Tab",bubbles:true,cancelable:true}))')
check('Tab wraps inside the modal rather than entering the sidebar', await read('document.activeElement.getAttribute("aria-label")'), '关闭技能详情')
await read('window.__detailBeforeInstall=document.querySelector("[role=dialog]")')
await click('[aria-label="确认安装推荐技能"]')
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"')
check('the install progress is reported by the host', await read('document.querySelector(".yc-skill-inline-install").dataset.installPhase'), 'installed')
check('the host received the recommendation id', await read(`JSON.stringify(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').map(c=>c[1]))`), '["archify"]')
check('the host received a fresh operation id', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>typeof c[2]?.operationId==='string'&&c[2].operationId.length>8))`), true)
check('a first install is not an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').every(c=>c[2]?.overwrite!==true))`), true)
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"')
check('the card reports the recommendation as installed', await read('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState'), 'current')
check('the installed skill is not offered for download again', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true)
check('the detail remains the same DOM instance throughout installation', await read('window.__detailBeforeInstall===document.querySelector("[role=dialog]")'), true)
check('the detail offers use after installation', await read('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")'), true)
check('the success notification also offers use', await read('document.querySelector(".yc-skill-install-success")?.textContent.includes("去使用")'), true)
check('there is no permanent list footer progress panel', await read('!!document.querySelector(".yc-skills-scroll > .yc-skill-inline-install")'), false)
await capture('installed-detail')
await click('[role=dialog] .sm-skill-detail-close')
// The installed list stays reachable and the skill can be used.
await click('.yc-skill-recommendation-footer button')
await wait('document.querySelectorAll(".sm-card").length===1')
check('the installed skill appears in the installed list', await read('document.querySelector(".sm-card-name")?.textContent'), 'Archify')
await click('[aria-label="返回专家中心"]')
await wait('!!document.querySelector("[data-recommendation-id=archify]")')
// A local skill with the same name requires an explicit overwrite.
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([localSkill])})`)
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="conflict"')
await click('[aria-label="安装 Archify"]')
await wait('!!document.querySelector(".yc-skill-detail-shell")')
check('a same-name local skill requires an explicit replacement', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("替换")'), true)
check('the replacement notice mentions the local modification', await read('document.querySelector(".yc-skill-detail-shell").textContent.includes("本地内容已被修改")'), true)
await click('[aria-label="确认安装推荐技能"]')
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="current"')
check('an explicit replacement is sent to the host as an overwrite', await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').at(-1)[2]?.overwrite)===true`), true)
await click('[role=dialog] .sm-skill-detail-close')
// A failing download reports the real host error and allows a retry.
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([])})`)
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"')
await read(`window.ycode.skills.qaFailNext()`)
await click('[aria-label="安装 Archify"]')
await click('[aria-label="确认安装推荐技能"]')
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="failed"')
check('a failed install shows the host error', await read('document.querySelector(".yc-skill-inline-install").textContent.includes("网络错误")'), true)
check('a failed install can be retried from the same card', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), false)
await click('[role=dialog] .sm-skill-detail-close')
// Cancellation is allowed before the atomic commit phase.
await read(`window.ycode.skills.qaDelay(400)`)
await click('[aria-label="安装 Archify"]')
await click('[aria-label="确认安装推荐技能"]')
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="downloading"')
check('the download phase can be cancelled', await read('[...document.querySelectorAll("[role=dialog] button")].some(e=>e.textContent==="取消安装"&&!e.disabled)'), true)
await read('document.querySelector("[role=dialog]").querySelectorAll("button").forEach(e=>{if(e.textContent==="取消安装")e.click()})')
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="cancelled"')
check('the cancellation is sent to the host', await read(`${JSON.stringify(calls)}.some(c=>c[0]==='cancelInstall')`), true)
await click('[role=dialog] .sm-skill-detail-close')
await read(`window.ycode.skills.qaDelay(0)`)
// Closing the detail never aborts an accepted install; reopening resumes it.
await read('window.ycode.skills.qaDelay(250)')
await click('[aria-label="安装 Archify"]'); await click('[aria-label="确认安装推荐技能"]')
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="downloading"')
const continuingId = await read('document.querySelector(".yc-skill-inline-install").dataset.operationId')
await click('[role=dialog] .sm-skill-detail-close')
check('closing a running detail restores the background', await read('document.getElementById("root").inert'), false)
await click('[data-recommendation-id=archify] .sm-card')
check('reopening detail keeps the same install operation', await read('document.querySelector(".yc-skill-inline-install").dataset.operationId'), continuingId)
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installing"')
check('the commit phase disables cancellation in place', await read('[...document.querySelectorAll("[role=dialog] button")].find(e=>e.textContent==="取消安装")?.disabled'), true)
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"')
await click('[role=dialog] .sm-skill-detail-close')
await click('.yc-skill-install-success [aria-label="使用推荐技能 Archify"]')
check('the success notification invokes the same use callback', await read('window.__qa.uses.length'), 1)
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([{ ...installedRecommendation, enabled: false }])})`)
await wait('document.querySelector(".yc-skill-recommendation-meta")?.textContent.includes("启用后使用")')
await click('.yc-skill-recommendation-meta [aria-label="使用推荐技能 Archify"]')
await wait('window.__qa.uses.length===2')
check('disabled recommendation is enabled before use', await read('window.__qa.uses.at(-1).enabled'), true)
await read('window.ycode.skills.qaDelay(0)'); await read('window.ycode.skills.qaSetInstalled([])')
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"')
// A failing catalog is reported as unavailable, not as a maintenance page.
// The page loads its catalog once, so the failure is proven on a fresh mount.
await read(`window.ycode.skills.qaFailRecommendations(true)`)
await read(`location.reload()`)
await wait('!!document.querySelector(".yc-skill-recommendation-unavailable")')
check('an empty catalog is reported as unavailable, not as a maintenance page', await read('!!document.querySelector("[data-capability-maintenance]")'), false)
check('the unavailable state still offers the installed list', await read('!!document.querySelector(".yc-skill-recommendation-unavailable button")'), true)
await read(`window.ycode.skills.qaFailRecommendations(false)`)
await read(`location.reload()`)
await wait('!!document.querySelector("[data-recommendation-id=archify]")')
// An install owned by another window blocks a new install here.
const foreignOperation = '11111111-2222-4333-8444-555555555555'
await read(`window.ycode.skills.qaEmitForeign('downloading', ${JSON.stringify(foreignOperation)})`)
await wait('!!document.querySelector("[data-foreign-install=true]")')
check('an install from another window is reported', await read('document.querySelector("[data-foreign-install=true]").textContent.includes("其他窗口正在安装推荐技能")'), true)
check('a new install is blocked while another window is installing', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), true)
check('the blocked state is explained on the card', await read('!!document.querySelector(".yc-skill-recommendation-busy")'), true)
await capture('foreign-install')
await read(`window.ycode.skills.qaEmitForeign('installed', ${JSON.stringify(foreignOperation)})`)
await wait('!document.querySelector("[data-foreign-install=true]")')
check('the foreign block clears when that operation finishes', await read('document.querySelector("[data-recommendation-id=archify] .sm-add-btn")?.disabled'), false)
// A stale frame must not move a settled operation back into a running phase.
await read(`window.ycode.skills.qaSetInstalled(${JSON.stringify([])})`)
await wait('document.querySelector("[data-recommendation-id=archify]").dataset.recommendationState==="none"')
await read(`window.ycode.skills.qaDelay(0)`)
await click('[aria-label="安装 Archify"]')
await click('[aria-label="确认安装推荐技能"]')
await wait('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase==="installed"')
const settledOperation = await read(`(${JSON.stringify(calls)}.filter(c=>c[0]==='installRecommended').at(-1)[2]?.operationId)`)
check('the settled install recorded its operation id', typeof settledOperation === 'string' && settledOperation.length > 8, true)
await read(`window.ycode.skills.qaEmitStale('downloading', ${JSON.stringify(settledOperation)}, 1)`)
await pause(250)
check('a stale frame does not regress the settled install', await read('document.querySelector(".yc-skill-inline-install")?.dataset.installPhase'), 'installed')
await click('[role=dialog] .sm-skill-detail-close')
// Dark and narrow captures of the real page.
// This fixture mounts only the capability center, so the theme attribute is set directly.
// The dark token switch is proven here only; the production app harness covers the light flow.
await read(`(()=>{localStorage.setItem('ycode:theme','dark');document.documentElement.dataset.theme='dark';return true})()`)
await wait('document.documentElement.dataset.theme==="dark"')
await wait('!!document.querySelector(".yc-capability-center .expert-center-dark")')
check('the dark theme reaches the real page', await read('!!document.querySelector(".yc-capability-center .expert-center-dark")'), true)
// The card transitions its background, so the dark surface only settles after the animation.
await wait(`(()=>{const card=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!card)return false;const m=/rgba?\\((\\d+)/.exec(getComputedStyle(card).backgroundColor);return !!m&&Number(m[1])<90})()`)
const darkColors = await read(`(()=>{const card=document.querySelector('[data-recommendation-id=archify] .sm-card');const page=document.querySelector('.yc-capability-center .expert-center-page');return{card:card?getComputedStyle(card).backgroundColor:null,page:page?getComputedStyle(page).backgroundColor:null}})()`)
check('the dark card uses a dark surface', darkColors.card, value => typeof value === 'string' && /rgba?\((\d+), (\d+), (\d+)/.test(value) && Number(value.match(/rgba?\((\d+)/)[1]) < 90)
const overflow = await read(`(()=>{const wide=[...document.querySelectorAll('*')].filter(e=>e.getBoundingClientRect().right>innerWidth+1).map(e=>e.className||e.tagName).slice(0,4);return{scrollWidth:document.documentElement.scrollWidth,innerWidth,wide}})()`)
check('the dark layout keeps the recommendation card inside the viewport', await read(`(()=>{const c=document.querySelector('[data-recommendation-id=archify] .sm-card');if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()`), true)
check('the dark layout does not overflow the document', overflow.scrollWidth <= overflow.innerWidth + 1, true)
await capture('recommendation-dark')
win.setSize(720, 900)
await pause(400)
check('the recommendation card stays inside the narrow viewport', await read('(()=>{const c=document.querySelector("[data-recommendation-id=archify] .sm-card");if(!c)return false;const r=c.getBoundingClientRect();return r.left>=0&&r.right<=innerWidth+1})()'), true)
const narrowOverflow = await read(`(()=>{const wide=[...document.querySelectorAll('*')].filter(e=>e.getBoundingClientRect().right>innerWidth+1).map(e=>String(e.className||e.tagName)).slice(0,5);return{scrollWidth:document.documentElement.scrollWidth,innerWidth,wide}})()`)
check('the narrow recommendation card causes no document overflow', narrowOverflow.scrollWidth <= narrowOverflow.innerWidth + 1, true)
await capture('recommendation-narrow')
win.setSize(1052, 901)
await pause(250)
check('no renderer errors', errors, [])
fs.writeFileSync(path.join(output, 'report.json'), JSON.stringify({
result: 'PASS', checks, calls, screenshots,
limits: 'Controlled host in isolated Electron; the shipped page and styles are used. No network, no real skill storage, no real install.'
}, null, 2))
console.log(JSON.stringify({ result: 'PASS', checks: checks.length, output }))
win.destroy()
app.exit(0)
} catch (error) {
await capture('failure')
fs.writeFileSync(path.join(output, 'failure.json'), JSON.stringify({ error: String(error), stack: error?.stack, checks, errors, calls, html: await read('document.body.innerHTML') }, null, 2))
console.error(error)
win.destroy()
app.exit(1)
}
})().catch(error => { console.error(error); electron.app.exit(1) })
}
+3 -3
View File
@@ -39,7 +39,7 @@ if (!electron) {
fs.writeFileSync(path.join(output,'preload.cjs'), `const{contextBridge,ipcRenderer}=require('electron');contextBridge.exposeInMainWorld('ycode',{skills:Object.fromEntries(['list','read','setEnabled','remove','export','importPath'].map(method=>[method,(...args)=>ipcRenderer.invoke('skills-qa',method,args)]))});`)
const location = file=>JSON.stringify(path.join(repository,file).replaceAll('\\','/'))
fs.writeFileSync(path.join(output,'fixture.tsx'), `import React from 'react';import{createRoot}from'react-dom/client';import Center from ${location('src/renderer/src/capabilities/CapabilityCenter')};import ${location('src/renderer/src/styles/tokens.css')};import ${location('src/renderer/src/styles/app.css')};window.__skillsQa={uses:[],created:0,find:0};createRoot(document.getElementById('root')).render(<div className="chat-pane cr-theme" style={{height:'100vh'}}><Center cwd={${JSON.stringify(workspace)}} onBackToChat={()=>{}} onUseSkill={skill=>window.__skillsQa.uses.push(skill)} onCreateSkill={()=>window.__skillsQa.created++} onFindSkill={()=>window.__skillsQa.find++}/></div>);`)
await require('esbuild').build({entryPoints:[path.join(output,'fixture.tsx')],bundle:true,platform:'browser',format:'esm',jsx:'automatic',outfile:path.join(output,'fixture.js'),loader:{'.svg':'file','.png':'file','.woff':'file','.woff2':'file','.ttf':'file'},logLevel:'warning'})
await require('esbuild').build({entryPoints:[path.join(output,'fixture.tsx')],bundle:true,platform:'browser',format:'esm',jsx:'automatic',outfile:path.join(output,'fixture.js'),loader:{'.svg':'file','.png':'file','.jpg':'file','.woff':'file','.woff2':'file','.ttf':'file'},logLevel:'warning'})
fs.writeFileSync(path.join(output,'index.html'),'<html data-theme="light"><head><meta charset="utf-8"><link rel="stylesheet" href="fixture.css"><style>html,body,#root{width:100%;height:100%;margin:0}</style></head><body><div id="root"></div><script type="module" src="fixture.js"></script></body></html>')
const win=new BrowserWindow({width:1052,height:901,useContentSize:true,frame:false,show:false,webPreferences:{preload:path.join(output,'preload.cjs'),contextIsolation:true,nodeIntegration:false,backgroundThrottling:false}})
win.webContents.on('console-message',(_event,level,message)=>{if(level===3)errors.push(message)})
@@ -67,9 +67,9 @@ if (!electron) {
check('success notice does not affect normal flow',await read('getComputedStyle(document.querySelector(".yc-skill-notice")).position'),'absolute');await wait('!document.querySelector(".yc-skill-notice")');check('success notice disappears automatically',await read('!!document.querySelector(".yc-skill-notice")'),false)
await clickText('.sm-installed-batch-manage','批量管理');await click('.sm-card');await clickText('.sm-batch-toolbar button','禁用');check('batch disable persists real state',(await api.listSkills(workspace))[0].enabled,false);await clickText('.sm-batch-toolbar button','启用');check('batch enable persists real state',(await api.listSkills(workspace))[0].enabled,true);await clickText('.sm-installed-batch-manage','完成')
await click('.wb-switch__input');check('native toggle persists actual disabled state',(await api.listSkills(workspace))[0].enabled,false)
await click('.wb-switch__input');await click('.sm-card-name');await wait('document.querySelector(".sm-skill-detail-overview-body")?.textContent.includes("NATIVE_SKILL_PROOF")');check('detail renders real SKILL.md markdown',await read('document.querySelector(".sm-skill-detail-overview-body").textContent.includes("Actual skill instructions")'),true);await capture('detail')
await click('.wb-switch__input');await click('.sm-card-name');await wait('document.querySelector(".yc-skill-description-body")?.textContent.includes("NATIVE_SKILL_PROOF")');check('detail renders real SKILL.md markdown',await read('document.querySelector(".yc-skill-description-body").textContent.includes("Actual skill instructions")'),true);await capture('detail')
await click('[aria-label="使用技能"]');check('use callback receives actual enabled identity',await read('window.__skillsQa.uses[0].id'),(await api.listSkills(workspace))[0].id)
await clickText('.sm-skill-detail-action-row button','导出');check('export writes real ZIP',fs.existsSync(path.join(output,'export.zip')),true)
await click('.sm-card-name');await wait('!!document.querySelector(".sm-skill-detail-card")');await clickText('.sm-skill-detail-action-row button','导出');check('export writes real ZIP',fs.existsSync(path.join(output,'export.zip')),true)
await click('[aria-label="卸载技能"]');await key('Escape');await settle();check('nested Escape closes only top uninstall dialog',await read('!!document.querySelector(".sm-skill-detail-card")&&!document.querySelector("[aria-label=确认卸载]")'),true)
await click('[aria-label="关闭技能详情"]');await click('[aria-label="搜索技能"]');await cdp('Input.insertText',{text:'NO_MATCH'});await settle();check('real search filters installed list',await read('document.querySelectorAll(".sm-card").length'),0);await click('[aria-label="清空技能搜索"]');await wait('document.querySelectorAll(".sm-card").length===1')
await click('.sm-card-pin');check('pin updates shared slash menu memory',await read(`JSON.parse(localStorage.getItem(${JSON.stringify('ycode.skills.menu.v1:'+workspace.replaceAll('\\','/').toLowerCase())})).pinned.length`),1)
+827
View File
@@ -0,0 +1,827 @@
import assert from 'node:assert/strict'
import { createHash, generateKeyPairSync } from 'node:crypto'
import { createServer, type ServerResponse } from 'node:http'
import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { signPayload as releaseSignPayload } from './updates/lib.mjs'
import { FileUpdatePersistence } from '../src/main/updates/storage'
import { compareVersions, parseVersion } from '../src/main/updates/semver'
import { normalizeManifestUrl, verifySignedManifest } from '../src/main/updates/manifest'
import { isTrustedUpdateRendererUrl } from '../src/main/updates/trust'
import { createNsisInstaller, NSIS_UNINSTALL_KEY } from '../src/main/updates/installer'
import { sanitizeLogText } from '../src/main/updates/logger'
import { UpdateManager } from '../src/main/updates/manager'
import {
UpdateError,
type CachedInstaller,
type FetchLike,
type InstallEnvironment,
type InstallerAdapter,
type SignedUpdateManifest,
type UpdateManifestPayload
} from '../src/main/updates/types'
const tests: Array<[string, () => Promise<void> | void]> = []
const test = (name: string, run: () => Promise<void> | void): void => {
tests.push([name, run])
}
const sleep = (milliseconds: number): Promise<void> => new Promise(resolve => setTimeout(resolve, milliseconds))
async function waitFor(predicate: () => boolean | Promise<boolean>, timeout = 5_000): Promise<void> {
const deadline = Date.now() + timeout
while (!await predicate()) {
if (Date.now() >= deadline) throw new Error('Timed out waiting for test condition')
await sleep(10)
}
}
function sha256(bytes: Buffer): string {
return createHash('sha256').update(bytes).digest('hex')
}
function makeBytes(size: number, fill: number): Buffer {
const bytes = Buffer.allocUnsafe(size)
for (let index = 0; index < size; index += 1) bytes[index] = (fill + index) & 0xff
return bytes
}
function expectUpdateError(run: () => unknown, code: string): void {
assert.throws(run, (error: unknown) => error instanceof UpdateError && error.code === code)
}
const { privateKey, publicKey } = generateKeyPairSync('ed25519')
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
const publicPem = publicKey.export({ type: 'spki', format: 'pem' }).toString()
function manifestFor(
origin: string,
version: string,
bytes: Buffer,
notes = { zh: `更新 ${version}`, en: `Release ${version}` },
filename = `YCode-${version}-Setup.exe`
): SignedUpdateManifest {
const payload: UpdateManifestPayload = {
schemaVersion: 1,
version,
publishedAt: '2026-10-20T09:00:00.000Z',
notes,
platforms: {
'windows-x64': {
url: `${origin}/releases/versions/${version}/${filename}`,
size: bytes.byteLength,
sha256: sha256(bytes)
}
}
}
return {
payload,
signature: releaseSignPayload(payload, privatePem)
}
}
interface HttpFixture {
latest: SignedUpdateManifest | null
files: Map<string, Buffer>
requests: string[]
downloadRanges: Array<string | null>
redirect: 'none' | 'same-origin' | 'cross-origin'
crossOrigin: string
oversizeManifest: boolean
interruptNextDownload: boolean
chunkSize: number
chunkDelayMs: number
downloadStarted: number
origin: string
close: () => Promise<void>
}
async function sendBody(response: ServerResponse, body: Buffer, fixture: HttpFixture): Promise<void> {
for (let offset = 0; offset < body.byteLength; offset += fixture.chunkSize) {
if (response.destroyed) return
const chunk = body.subarray(offset, Math.min(body.byteLength, offset + fixture.chunkSize))
if (!response.write(chunk)) await new Promise<void>(resolve => response.once('drain', resolve))
if (fixture.chunkDelayMs > 0) await sleep(fixture.chunkDelayMs)
}
if (!response.destroyed) response.end()
}
async function createHttpFixture(): Promise<HttpFixture> {
let server: ReturnType<typeof createServer>
const fixture: HttpFixture = {
latest: null,
files: new Map(),
requests: [],
downloadRanges: [],
redirect: 'none',
crossOrigin: '',
oversizeManifest: false,
interruptNextDownload: false,
chunkSize: 64 * 1024,
chunkDelayMs: 0,
downloadStarted: 0,
origin: '',
close: async () => {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
}
}
server = createServer((request, response) => {
response.on('error', () => undefined)
const url = new URL(request.url ?? '/', fixture.origin)
fixture.requests.push(url.pathname)
if (url.pathname === '/releases/latest.json') {
if (fixture.oversizeManifest) {
const length = 1024 * 1024 + 1
response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(length) })
response.end(Buffer.alloc(length, 0x20))
return
}
if (fixture.redirect === 'same-origin' && !url.searchParams.has('redirected')) {
response.writeHead(302, { location: '/releases/latest.json?redirected=1' })
response.end()
return
}
if (fixture.redirect === 'cross-origin') {
response.writeHead(302, { location: `${fixture.crossOrigin}/releases/latest.json` })
response.end()
return
}
const body = Buffer.from(JSON.stringify(fixture.latest))
response.writeHead(200, { 'content-type': 'application/json', 'content-length': String(body.byteLength) })
response.end(body)
return
}
const file = fixture.files.get(url.pathname)
if (!file) {
response.writeHead(404)
response.end()
return
}
const rangeMatch = /^bytes=(\d+)-$/.exec(request.headers.range ?? '')
const range = rangeMatch ? Number(rangeMatch[1]) : null
fixture.downloadRanges.push(rangeMatch?.[0] ?? null)
fixture.downloadStarted += 1
if (range !== null && range >= file.byteLength) {
response.writeHead(416, { 'content-range': `bytes */${file.byteLength}` })
response.end()
return
}
const start = range ?? 0
if (fixture.interruptNextDownload && start === 0) {
fixture.interruptNextDownload = false
const partial = file.subarray(0, Math.max(1, Math.floor(file.byteLength / 2)))
response.writeHead(200, {
'content-type': 'application/octet-stream',
'content-length': String(file.byteLength)
})
response.write(partial)
setTimeout(() => response.destroy(new Error('simulated interruption')), 20)
return
}
response.writeHead(start > 0 ? 206 : 200, {
'content-type': 'application/octet-stream',
'content-length': String(file.byteLength - start),
...(start > 0 ? { 'content-range': `bytes ${start}-${file.byteLength - 1}/${file.byteLength}` } : {})
})
void sendBody(response, file.subarray(start), fixture).catch(() => undefined)
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => resolve())
})
const address = server.address()
if (!address || typeof address === 'string') throw new Error('Fake HTTP server did not bind')
fixture.origin = `http://127.0.0.1:${address.port}`
return fixture
}
class FakeInstaller implements InstallerAdapter {
validateCount = 0
verifyCount = 0
launchCount = 0
launchError: Error | null = null
onLaunch: (() => Promise<void>) | null = null
async validateEnvironment(_requiredBytes?: number): Promise<InstallEnvironment> {
this.validateCount += 1
return {
installationDirectory: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode',
executablePath: 'C:\\Users\\Test\\AppData\\Local\\Programs\\YCode\\YCode.exe',
registryKey: `HKCU\\${NSIS_UNINSTALL_KEY}`
}
}
async verifyInstaller(_installer: CachedInstaller): Promise<void> {
this.verifyCount += 1
}
async launch(_installer: CachedInstaller, _environment: InstallEnvironment): Promise<void> {
this.launchCount += 1
if (this.onLaunch) await this.onLaunch()
if (this.launchError) throw this.launchError
}
}
interface ManagerFixture {
manager: UpdateManager
installer: FakeInstaller
persistence: FileUpdatePersistence
cacheDirectory: string
controls: {
beforeResult: boolean
beforeGate: Promise<boolean> | null
beforeCalls: number
failedCalls: number
quitCalls: number
quitError: Error | null
}
}
let managerSequence = 0
function createManagerFixture(
root: string,
origin: string,
options: { autoDownload?: boolean; currentVersion?: string; identity?: string } = {}
): ManagerFixture {
managerSequence += 1
const directory = join(root, `manager-${options.identity ?? managerSequence}`)
const installer = new FakeInstaller()
const persistence = new FileUpdatePersistence(join(directory, 'state.json'))
const controls: ManagerFixture['controls'] = {
beforeResult: true,
beforeGate: null,
beforeCalls: 0,
failedCalls: 0,
quitCalls: 0,
quitError: null
}
const manager = new UpdateManager({
currentVersion: options.currentVersion ?? '0.0.1',
manifestUrl: `${origin}/releases/latest.json`,
cacheDirectory: join(directory, 'cache'),
logFile: join(directory, 'updater.log'),
persistence,
publicKey: async () => publicPem,
supported: true,
installer,
beforeInstall: async () => {
controls.beforeCalls += 1
if (controls.beforeGate) return controls.beforeGate
return controls.beforeResult
},
quitForUpdate: async () => {
controls.quitCalls += 1
if (controls.quitError) throw controls.quitError
},
installFailed: async () => {
controls.failedCalls += 1
},
autoDownload: options.autoDownload ?? false,
checkDelayMs: 5,
checkIntervalMs: 60_000,
upToDateResetMs: 30,
manifestTimeoutMs: 2_000,
downloadTimeoutMs: 10_000
})
return { manager, installer, persistence, cacheDirectory: join(directory, 'cache'), controls }
}
test('strict SemVer parser and comparator', () => {
assert.deepEqual(parseVersion('1.2.3-alpha.1+build.7').prerelease, ['alpha', '1'])
assert.equal(compareVersions('1.2.3+build.1', '1.2.3+build.2'), 0)
assert.equal(compareVersions('1.2.3-alpha.2', '1.2.3-alpha.10'), -1)
assert.equal(compareVersions('1.2.3', '1.2.3-rc.1'), 1)
expectUpdateError(() => parseVersion('01.2.3'), 'invalid-version')
expectUpdateError(() => parseVersion('1.2.3-01'), 'invalid-version')
})
test('manifest signature, exact metadata and URL policy', async () => {
const origin = 'http://127.0.0.1:65530'
const bytes = makeBytes(1024, 5)
const manifest = manifestFor(origin, '1.0.1', bytes)
const verified = verifySignedManifest(Buffer.from(JSON.stringify(manifest)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
})
assert.equal(verified.payload.version, '1.0.1')
const tampered = structuredClone(manifest)
tampered.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
expectUpdateError(
() => verifySignedManifest(Buffer.from(JSON.stringify(tampered)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
}),
'invalid-signature'
)
const invalidVersionPayload = structuredClone(manifest.payload) as unknown as Record<string, unknown>
invalidVersionPayload.version = '01.0.0'
const invalidVersionDocument = {
payload: invalidVersionPayload,
signature: releaseSignPayload(invalidVersionPayload, privatePem)
}
expectUpdateError(
() => verifySignedManifest(Buffer.from(JSON.stringify(invalidVersionDocument)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
}),
'invalid-version'
)
const outsidePath = structuredClone(manifest.payload)
outsidePath.platforms['windows-x64'].url = `${origin}/releases/other/YCode.exe`
const outsidePathDocument = {
payload: outsidePath,
signature: releaseSignPayload(outsidePath, privatePem)
}
expectUpdateError(
() => verifySignedManifest(Buffer.from(JSON.stringify(outsidePathDocument)), {
manifestUrl: `${origin}/releases/latest.json`,
publicKeyPem: publicPem
}),
'invalid-manifest'
)
assert.equal(normalizeManifestUrl(`${origin}/releases/latest.json`), `${origin}/releases/latest.json`)
})
test('trusted renderer URL gate rejects task and foreign frames', () => {
const appPath = 'C:\\Program Files\\YCode\\resources\\app.asar'
const mainFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\index.html').href
const settingsFile = pathToFileURL('C:\\Program Files\\YCode\\resources\\app.asar\\out\\renderer\\settings.html').href
assert.equal(isTrustedUpdateRendererUrl(mainFile, { appPath, platform: 'win32' }), true)
assert.equal(isTrustedUpdateRendererUrl(settingsFile, { appPath, platform: 'win32' }), true)
assert.equal(isTrustedUpdateRendererUrl(`${mainFile}?window=task`, { appPath, platform: 'win32' }), false)
assert.equal(isTrustedUpdateRendererUrl('file:///C:/Windows/System32/drivers/etc/hosts', { appPath, platform: 'win32' }), false)
assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/index.html?window=task', {
appPath,
rendererUrl: 'http://localhost:5173'
}), false)
assert.equal(isTrustedUpdateRendererUrl('http://localhost:5173/settings.html', {
appPath,
rendererUrl: 'http://localhost:5173'
}), true)
assert.equal(isTrustedUpdateRendererUrl('http://127.0.0.1:5173/settings.html', {
appPath,
rendererUrl: 'http://localhost:5173'
}), false)
})
test('installer adapter uses spawn, current key and NSIS arguments', async () => {
assert.equal(NSIS_UNINSTALL_KEY.includes('8a4ee941-d893-569b-8bcb-2a55d270c04b'), true)
const root = 'C:\\Users\\Test\\AppData\\Local\\YCode Custom\\Install Dir'
const executable = `${root}\\YCode.exe`
const cacheDirectory = join(await mkdtemp(join(tmpdir(), 'ycode-update-adapter-')), 'cache')
const calls: Array<{ command: string; args: readonly string[]; detached: boolean; stdio: string }> = []
const child = {
once(event: 'spawn' | 'error', listener: (() => void) | ((error: Error) => void)) {
if (event === 'spawn') queueMicrotask(() => (listener as () => void)())
return child
},
unref() {}
}
const baseOptions = {
platform: 'win32',
isPackaged: true,
execPath: executable,
appPath: `${root}\\resources\\app.asar`,
cacheDirectory,
processId: 4321,
resolveRealPath: async value => value,
readStat: async () => ({ isFile: () => true }),
queryRegistryValue: async name => {
if (name === 'UninstallString') return `"${root}\\Uninstall YCode.exe" /currentuser`
if (name === 'DisplayIcon') return `${executable},0`
return null
},
verifyFile: async () => undefined,
spawn: (command, args, options) => {
calls.push({ command, args, detached: options.detached, stdio: options.stdio })
return child
}
} as const
const adapter = createNsisInstaller({
...baseOptions,
readDiskStats: async () => ({ bsize: 4096, bavail: 1_000_000 })
})
const environment = await adapter.validateEnvironment(1024)
assert.equal(environment.installationDirectory, root)
const cached: CachedInstaller = {
version: '1.0.2',
path: join(cacheDirectory, 'YCode-1.0.2-Setup.exe'),
partPath: join(cacheDirectory, 'YCode-1.0.2-Setup.exe.part'),
size: 10,
sha256: 'a'.repeat(64)
}
await adapter.launch(cached, environment)
assert.equal(calls.length, 1)
assert.equal(calls[0].detached, true)
assert.equal(calls[0].stdio, 'ignore')
assert.deepEqual([...calls[0].args], [
'/S',
'--updated',
'--force-run',
'/currentuser',
'/YCODEWAITPID=4321',
`/D=${root}`
])
assert.equal(calls[0].args.at(-1), `/D=${root}`)
const lowSpace = createNsisInstaller({
...baseOptions,
readDiskStats: async () => ({ bsize: 1, bavail: 1 })
})
await assert.rejects(
() => lowSpace.validateEnvironment(1024),
(error: unknown) => error instanceof UpdateError && error.code === 'installer-space-check'
)
const directoryMarker = createNsisInstaller({
...baseOptions,
readStat: async value => ({ isFile: () => !value.endsWith('app.asar') })
})
await assert.rejects(
() => directoryMarker.validateEnvironment(0),
(error: unknown) => error instanceof UpdateError
&& error.code === 'installer-marker'
&& /app\.asar/.test(error.message)
)
})
test('HTTP manifest limits, same-origin redirects and cross-origin rejection', async () => {
const first = await createHttpFixture()
const second = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-http-'))
try {
const bytes = makeBytes(256 * 1024, 11)
first.latest = manifestFor(first.origin, '1.0.1', bytes)
first.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
first.redirect = 'same-origin'
const redirected = createManagerFixture(root, first.origin, { identity: 'redirect' })
const redirectedState = await redirected.manager.check()
assert.equal(redirectedState.status, 'available')
first.redirect = 'cross-origin'
first.crossOrigin = second.origin
second.latest = first.latest
const cross = createManagerFixture(root, first.origin, { identity: 'cross' })
const crossState = await cross.manager.check()
assert.equal(crossState.status, 'error')
assert.match(crossState.error ?? '', /跨源/)
first.redirect = 'none'
first.oversizeManifest = true
const oversized = createManagerFixture(root, first.origin, { identity: 'oversize' })
const oversizedState = await oversized.manager.check()
assert.equal(oversizedState.status, 'error')
assert.match(oversizedState.error ?? '', /大小限制/)
} finally {
await first.close()
await second.close()
await rm(root, { recursive: true, force: true })
}
})
test('automatic download, dismissal and up-to-date idle reset', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-auto-'))
try {
const bytes = makeBytes(192 * 1024, 17)
http.latest = manifestFor(http.origin, '1.0.1', bytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
const auto = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'auto' })
const checked = await auto.manager.checkAndDownload(false)
assert.equal(checked.status, 'downloading')
assert.equal(checked.version, '1.0.1')
assert.equal(checked.lastExplicit, false)
await waitFor(async () => (await auto.manager.getState()).status === 'ready')
assert.equal((await auto.manager.getState()).progress, 100)
assert.equal((await auto.manager.getState()).lastExplicit, false)
http.oversizeManifest = true
const retainedReady = await auto.manager.check(false)
assert.equal(retainedReady.status, 'ready')
assert.equal(retainedReady.lastExplicit, false)
http.oversizeManifest = false
const dismissed = await auto.manager.dismiss()
assert.equal(dismissed.dismissedVersion, '1.0.1')
assert.equal(dismissed.lastExplicit, true)
const persisted = await auto.persistence.load()
assert.equal(persisted.dismissedVersion, '1.0.1')
const second = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'dismissed' })
await second.persistence.save({ schemaVersion: 1, dismissedVersion: '1.0.1' })
const before = http.requests.length
const dismissedCheck = await second.manager.checkAndDownload()
await sleep(80)
assert.equal(dismissedCheck.dismissedVersion, '1.0.1')
assert.equal(dismissedCheck.lastExplicit, true)
assert.equal(http.requests.length - before, 1)
http.latest = manifestFor(http.origin, '0.0.1', bytes)
const current = createManagerFixture(root, http.origin, { autoDownload: true, identity: 'current' })
const upToDate = await current.manager.check()
assert.equal(upToDate.status, 'up-to-date')
await sleep(60)
assert.equal((await current.manager.getState()).status, 'idle')
http.latest = manifestFor(http.origin, '1.0.1', bytes)
const explicit = createManagerFixture(root, http.origin, { identity: 'explicit-reset' })
assert.equal((await explicit.manager.check(true)).lastExplicit, true)
http.oversizeManifest = true
const backgroundFailure = await explicit.manager.check(false)
assert.equal(backgroundFailure.status, 'error')
assert.equal(backgroundFailure.lastExplicit, false)
http.oversizeManifest = false
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('download interruption resumes with valid Range and fixed cache filename', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-resume-'))
try {
const bytes = makeBytes(2 * 1024 * 1024, 23)
http.latest = manifestFor(http.origin, '1.0.1', bytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'resume' })
assert.equal((await fixture.manager.check()).status, 'available')
http.interruptNextDownload = true
const interrupted = await fixture.manager.download()
assert.equal(interrupted.status, 'error')
const partInfo = await stat(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe.part'))
assert.ok(partInfo.size > 0)
const resumed = await fixture.manager.download()
assert.equal(resumed.status, 'ready')
assert.ok(http.downloadRanges.some(range => range?.startsWith('bytes=')))
const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'))
assert.equal(sha256(cached), sha256(bytes))
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('concurrent download is single-flight and release switch aborts stale bytes', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-race-'))
try {
const firstBytes = makeBytes(4 * 1024 * 1024, 31)
const secondBytes = makeBytes(384 * 1024, 47)
http.latest = manifestFor(http.origin, '1.0.1', firstBytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', firstBytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', secondBytes)
http.chunkSize = 32 * 1024
http.chunkDelayMs = 15
const fixture = createManagerFixture(root, http.origin, { identity: 'race' })
assert.equal((await fixture.manager.check()).status, 'available')
const staleDownload = fixture.manager.download()
await waitFor(() => http.downloadStarted > 0)
http.latest = manifestFor(http.origin, '1.0.2', secondBytes)
const switched = await fixture.manager.check()
assert.equal(switched.status, 'available')
assert.equal(switched.version, '1.0.2')
const staleResult = await staleDownload
assert.notEqual(staleResult.status, 'ready')
assert.equal((await fixture.manager.getState()).version, '1.0.2')
http.chunkDelayMs = 0
const rangeCount = http.downloadRanges.length
const [one, two] = await Promise.all([fixture.manager.download(), fixture.manager.download()])
assert.equal(one.status, 'ready')
assert.equal(two.status, 'ready')
assert.equal(http.downloadRanges.length - rangeCount, 1)
const cached = await readFile(join(fixture.cacheDirectory, 'YCode-1.0.2-Setup.exe'))
assert.equal(sha256(cached), sha256(secondBytes))
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('install cancellation, launch failure marker cleanup, and success ordering', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-install-'))
try {
const bytes = makeBytes(256 * 1024, 61)
http.latest = manifestFor(http.origin, '1.0.2', bytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'install' })
assert.equal((await fixture.manager.check()).status, 'available')
fixture.controls.beforeResult = false
const cancelled = await fixture.manager.install()
assert.equal(cancelled.status, 'ready')
assert.equal(fixture.installer.launchCount, 0)
assert.equal(fixture.controls.failedCalls, 0)
assert.equal((await fixture.manager.download()).status, 'ready')
fixture.controls.beforeResult = true
fixture.installer.launchError = new Error('spawn failed')
const failed = await fixture.manager.install()
assert.equal(failed.status, 'error')
assert.equal(fixture.controls.failedCalls, 1)
const cleared = await fixture.persistence.load()
assert.equal(cleared.unfinishedVersion, undefined)
assert.equal(cleared.target, undefined)
assert.equal((await fixture.manager.check()).status, 'ready')
fixture.installer.launchError = null
let markerAtLaunch: string | undefined
fixture.installer.onLaunch = async () => {
markerAtLaunch = (await fixture.persistence.load()).unfinishedVersion
}
const installed = await fixture.manager.install()
assert.equal(installed.status, 'installing')
assert.equal(markerAtLaunch, '1.0.2')
assert.equal(fixture.installer.validateCount >= 2, true)
assert.equal(fixture.installer.verifyCount >= 2, true)
assert.equal(fixture.installer.launchCount, 2)
assert.equal(fixture.controls.quitCalls, 1)
const retained = await fixture.persistence.load()
assert.equal(retained.unfinishedVersion, '1.0.2')
assert.equal(retained.target?.sha256, sha256(bytes))
const afterCommittedDismiss = await fixture.manager.dismiss()
assert.equal(afterCommittedDismiss.status, 'installing')
assert.equal(afterCommittedDismiss.dismissedVersion, undefined)
const afterCommittedInstall = await fixture.manager.install()
assert.equal(afterCommittedInstall.status, 'installing')
assert.equal(fixture.installer.launchCount, 2)
assert.equal(fixture.controls.beforeCalls, 3)
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('confirmation pending blocks dismiss and repeated install', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-confirm-lock-'))
try {
const bytes = makeBytes(128 * 1024, 67)
http.latest = manifestFor(http.origin, '1.0.2', bytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'confirm-lock' })
assert.equal((await fixture.manager.check()).status, 'available')
assert.equal((await fixture.manager.download()).status, 'ready')
let releaseConfirmation!: (accepted: boolean) => void
fixture.controls.beforeGate = new Promise(resolve => {
releaseConfirmation = resolve
})
const firstInstall = fixture.manager.install()
await waitFor(() => fixture.controls.beforeCalls === 1)
const dismissed = await fixture.manager.dismiss()
assert.equal(dismissed.status, 'ready')
assert.equal(dismissed.dismissedVersion, undefined)
const repeatedInstall = fixture.manager.install()
assert.equal(fixture.installer.launchCount, 0)
assert.equal(fixture.controls.beforeCalls, 1)
releaseConfirmation(false)
const [firstState, repeatedState] = await Promise.all([firstInstall, repeatedInstall])
assert.equal(firstState.status, 'ready')
assert.equal(repeatedState.status, 'ready')
assert.equal(fixture.installer.launchCount, 0)
assert.equal(fixture.controls.failedCalls, 0)
assert.equal((await fixture.persistence.load()).dismissedVersion, undefined)
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('committed installer blocks recovery after quit failure', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-committed-'))
try {
const bytes = makeBytes(128 * 1024, 73)
http.latest = manifestFor(http.origin, '1.0.2', bytes)
http.files.set('/releases/versions/1.0.2/YCode-1.0.2-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'committed' })
assert.equal((await fixture.manager.check()).status, 'available')
assert.equal((await fixture.manager.download()).status, 'ready')
fixture.controls.quitError = new Error('simulated quit failure')
const committed = await fixture.manager.install()
assert.equal(committed.status, 'installing')
assert.match(committed.error ?? '', /必须退出 YCode/)
assert.equal(fixture.installer.launchCount, 1)
assert.equal(fixture.controls.beforeCalls, 1)
assert.equal(fixture.controls.quitCalls, 1)
assert.equal(fixture.controls.failedCalls, 0)
const retained = await fixture.persistence.load()
assert.equal(retained.unfinishedVersion, '1.0.2')
assert.equal(retained.target?.version, '1.0.2')
const requestCount = http.requests.length
const dismissed = await fixture.manager.dismiss()
const checked = await fixture.manager.check()
const downloaded = await fixture.manager.download()
const checkedAndDownloaded = await fixture.manager.checkAndDownload()
const repeated = await fixture.manager.install()
for (const state of [dismissed, checked, downloaded, checkedAndDownloaded, repeated]) {
assert.equal(state.status, 'installing')
assert.match(state.error ?? '', /必须退出 YCode/)
}
assert.equal(http.requests.length, requestCount)
assert.equal(fixture.installer.launchCount, 1)
assert.equal(fixture.controls.beforeCalls, 1)
assert.equal(fixture.controls.quitCalls, 1)
assert.equal(fixture.controls.failedCalls, 0)
assert.equal((await fixture.persistence.load()).unfinishedVersion, '1.0.2')
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('corrupt cache is revalidated and rejected', async () => {
const http = await createHttpFixture()
const root = await mkdtemp(join(tmpdir(), 'ycode-update-cache-'))
try {
const bytes = makeBytes(128 * 1024, 71)
http.latest = manifestFor(http.origin, '1.0.1', bytes)
http.files.set('/releases/versions/1.0.1/YCode-1.0.1-Setup.exe', bytes)
const fixture = createManagerFixture(root, http.origin, { identity: 'cache' })
assert.equal((await fixture.manager.check()).status, 'available')
assert.equal((await fixture.manager.download()).status, 'ready')
await writeFile(join(fixture.cacheDirectory, 'YCode-1.0.1-Setup.exe'), Buffer.from('tampered'))
const checked = await fixture.manager.check()
assert.equal(checked.status, 'available')
const retried = await fixture.manager.download()
assert.equal(retried.status, 'ready')
} finally {
await http.close()
await rm(root, { recursive: true, force: true })
}
})
test('unfinished marker clears after target version is installed', async () => {
const root = await mkdtemp(join(tmpdir(), 'ycode-update-marker-'))
try {
const persistence = new FileUpdatePersistence(join(root, 'state.json'))
await persistence.save({
schemaVersion: 1,
unfinishedVersion: '1.0.0',
target: {
version: '1.0.0',
size: 10,
sha256: 'a'.repeat(64),
manifestUrl: 'http://127.0.0.1/releases/latest.json'
}
})
const manager = new UpdateManager({
currentVersion: '1.0.0',
manifestUrl: 'http://127.0.0.1/releases/latest.json',
cacheDirectory: join(root, 'cache'),
logFile: join(root, 'updater.log'),
persistence,
publicKey: async () => publicPem,
supported: true,
installer: new FakeInstaller(),
beforeInstall: async () => false,
quitForUpdate: async () => undefined
})
const state = await manager.getState()
assert.equal(state.unfinishedVersion, undefined)
const persisted = await persistence.load()
assert.equal(persisted.target, undefined)
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('log redaction removes URL credentials and query secrets', () => {
const sanitized = sanitizeLogText('GET http://user:password@example.test/a?token=secret Authorization: abc123')
assert.equal(sanitized.includes('password'), false)
assert.equal(sanitized.includes('secret'), false)
assert.equal(sanitized.includes('abc123'), false)
})
async function main(): Promise<void> {
let failures = 0
for (const [name, run] of tests) {
try {
await run()
console.log(`ok - ${name}`)
} catch (error) {
failures += 1
console.error(`not ok - ${name}`)
console.error(error)
}
}
console.log(`\n${tests.length - failures}/${tests.length} updater backend tests passed`)
if (failures > 0) process.exitCode = 1
}
void main()
+95
View File
@@ -0,0 +1,95 @@
import assert from 'node:assert/strict'
import { assertRuntimeAvailable, pauseRuntimeStarts, resumeRuntimeStarts, withRuntimeStart } from '../src/main/update-maintenance'
import { createUpdateLifecycle } from '../src/main/update-lifecycle'
let release!: () => void
const running = withRuntimeStart(() => new Promise<void>(resolve => { release = resolve }))
await Promise.resolve()
let paused = false
const pendingPause = pauseRuntimeStarts().then(() => { paused = true })
assert.throws(assertRuntimeAvailable, /准备重启/)
assert.throws(() => withRuntimeStart(async () => undefined), /准备重启/)
await Promise.resolve()
assert.equal(paused, false)
release()
await running
await pendingPause
assert.equal(paused, true)
resumeRuntimeStarts()
assert.doesNotThrow(assertRuntimeAvailable)
await assert.rejects(withRuntimeStart(async () => { throw new Error('failed start') }), /failed start/)
await pauseRuntimeStarts()
resumeRuntimeStarts()
assert.equal(await withRuntimeStart(async () => 42), 42)
console.log('PASS update maintenance: in-flight start drain, new work rejection, resume, rejected-start cleanup')
let resumed = 0
let finishDirectQuiesce!: () => void
let directQuiesceStarted!: () => void
const directQuiesceStart = new Promise<void>(resolve => { directQuiesceStarted = resolve })
let directQuiesceCalls = 0
const directLifecycle = createUpdateLifecycle({
quiesce: () => {
directQuiesceCalls += 1
directQuiesceStarted()
return new Promise<void>(resolve => { finishDirectQuiesce = resolve })
},
restore: () => { throw new Error('successful quiesce must not restore') },
shutdown: async () => undefined,
exit: () => undefined,
log: () => undefined
})
const directInstall = directLifecycle.beforeInstall()
await directQuiesceStart
assert.equal(directQuiesceCalls, 1, 'install starts quiesce without a confirmation port')
let directResolved = false
void directInstall.then(() => { directResolved = true })
await Promise.resolve()
assert.equal(directResolved, false, 'beforeInstall waits for quiesce completion')
await assert.rejects(directLifecycle.beforeInstall(), /上次更新准备仍在结束中/)
assert.equal(directQuiesceCalls, 1, 'concurrent beforeInstall must not start quiesce twice')
finishDirectQuiesce()
assert.equal(await directInstall, true)
let finishQuiesce!: () => void
const deferredLifecycle = createUpdateLifecycle({
quiesce: () => new Promise<void>(resolve => { finishQuiesce = resolve }),
restore: () => { resumed += 1 },
shutdown: async () => undefined,
exit: () => undefined,
log: () => undefined,
prepareTimeoutMs: 2
})
await assert.rejects(deferredLifecycle.beforeInstall(), /清理尚未结束/)
deferredLifecycle.installFailed()
assert.equal(resumed, 0, 'timeout must not resume while old quiesce is still running')
finishQuiesce()
await new Promise(resolve => setTimeout(resolve, 0))
assert.equal(resumed, 1, 'runtime resumes only after timed-out quiesce settles')
let exited = 0
let restarts = 0
let shutdowns = 0
const committedLifecycle = createUpdateLifecycle({
quiesce: async () => undefined,
restore: () => { restarts += 1 },
shutdown: async () => { shutdowns += 1 },
exit: () => { exited += 1 },
log: () => undefined
})
assert.equal(await committedLifecycle.beforeInstall(), true)
await committedLifecycle.quitForUpdate()
committedLifecycle.installFailed()
assert.deepEqual([shutdowns, exited, restarts], [1, 1, 0], 'committed installer exit must never restore work')
await assert.rejects(committedLifecycle.beforeInstall(), /安装器已启动/)
const hungShutdown = createUpdateLifecycle({
quiesce: async () => undefined,
restore: () => { throw new Error('must not restore committed installer') },
shutdown: () => new Promise<void>(() => undefined),
exit: () => { exited += 1 },
log: () => undefined,
quitTimeoutMs: 2
})
await hungShutdown.quitForUpdate()
assert.equal(exited, 2, 'hung shutdown is bounded and still releases the installer PID')
console.log('PASS update lifecycle: direct quiesce, concurrent barrier, timeout recovery, post-launch no-resume commit')
+84
View File
@@ -0,0 +1,84 @@
import assert from 'node:assert/strict'
import crypto from 'node:crypto'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { build } from 'esbuild'
import {
DEFAULT_BASE_URL,
DEFAULT_PUBLIC_KEY,
REPO_ROOT,
generateEd25519KeyPair,
normalizeBaseUrl,
readPublicKey,
signPayload
} from './lib.mjs'
async function main() {
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-contract-selftest-'))
try {
const bundlePath = path.join(temporary, 'manifest.mjs')
await build({
entryPoints: [path.join(REPO_ROOT, 'src', 'main', 'updates', 'manifest.ts')],
bundle: true,
platform: 'node',
format: 'esm',
outfile: bundlePath,
logLevel: 'silent'
})
const backend = await import(`${pathToFileURL(bundlePath).href}?v=${Date.now()}`)
assert.equal(typeof backend.verifySignedManifest, 'function')
const trustAnchor = await readPublicKey(DEFAULT_PUBLIC_KEY)
const trustKey = crypto.createPublicKey(trustAnchor)
assert.equal(trustKey.asymmetricKeyType, 'ed25519')
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl(DEFAULT_BASE_URL)
const payload = {
schemaVersion: 1,
version: '9.9.9',
publishedAt: '2026-10-20T10:00:00.000Z',
notes: { zh: '契约自测', en: 'Contract self-test' },
platforms: {
'windows-x64': {
url: `${baseUrl}versions/9.9.9/YCode-9.9.9-Setup-x64.exe`,
size: 123,
sha256: 'a'.repeat(64)
}
}
}
const document = {
payload,
signature: signPayload(payload, keys.privatePem)
}
const raw = Buffer.from(`${JSON.stringify(document)}\n`, 'utf8')
const verified = backend.verifySignedManifest(raw, {
manifestUrl: `${baseUrl}latest.json`,
publicKeyPem: keys.publicPem
})
assert.equal(verified.payload.version, payload.version)
assert.equal(verified.asset.url, payload.platforms['windows-x64'].url)
const tampered = structuredClone(document)
tampered.payload.notes.en = 'tampered'
assert.throws(
() => backend.verifySignedManifest(Buffer.from(JSON.stringify(tampered), 'utf8'), {
manifestUrl: `${baseUrl}latest.json`,
publicKeyPem: keys.publicPem
}),
/签名验证失败/
)
console.log('Release manifest matches update_backend parser/signature/path policy.')
console.log('Repository public key is a valid Ed25519 SPKI PEM trust anchor.')
} finally {
await fsp.rm(temporary, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+79
View File
@@ -0,0 +1,79 @@
import path from 'node:path'
import {
DEFAULT_BASE_URL,
DEFAULT_PUBLIC_KEY,
DEFAULT_SSH_HOST,
normalizeBaseUrl,
parseOptions,
resolvePath,
usageError
} from './lib.mjs'
import {
publishLocalRelease,
publishViaSsh,
readLatestViaSsh,
readLocalLatest,
validateReleaseBundle
} from './transport.mjs'
const usage = `Usage:
node scripts/updates/deploy.mjs --manifest <latest.json> --installer <YCode-Setup.exe>
[--transport ssh|local] [--base-url <url>] [--public-key <path>]
[--ssh-host local001] [--local-root <dir>] [--dry-run]
The SSH transport uploads to /srv/ycode-staging, verifies the current signed latest.json,
installs an immutable version directory, then atomically replaces latest.json.
The local transport is for temporary fake-transport tests only.
`
async function main() {
const options = parseOptions(process.argv.slice(2), {
values: ['manifest', 'installer', 'transport', 'base-url', 'public-key', 'ssh-host', 'local-root'],
booleans: ['dry-run']
})
if (!options.manifest || !options.installer) throw new Error('--manifest and --installer are required')
const transport = options.transport || 'ssh'
if (transport !== 'ssh' && transport !== 'local') throw new Error('--transport must be ssh or local')
if (transport === 'local' && !options['local-root']) throw new Error('--local-root is required for local transport')
const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
const release = await validateReleaseBundle({
manifestPath: resolvePath(options.manifest),
installerPath: resolvePath(options.installer),
publicKeyPath,
baseUrl
})
let current
if (transport === 'ssh') {
current = await readLatestViaSsh(options['ssh-host'] || DEFAULT_SSH_HOST, release.publicKeyPem, baseUrl)
} else {
current = await readLocalLatest(resolvePath(options['local-root']), release.publicKeyPem, baseUrl)
}
if (options['dry-run']) {
console.log(`Verified ${release.document.payload.version} for ${transport} publication.`)
console.log(`Current latest: ${current.document?.payload.version || 'none'}`)
return
}
if (transport === 'ssh') {
await publishViaSsh({
host: options['ssh-host'] || DEFAULT_SSH_HOST,
release,
current
})
} else {
await publishLocalRelease({
root: path.resolve(options['local-root']),
release,
current
})
}
console.log(`Published ${release.document.payload.version} via ${transport}.`)
}
main().catch((error) => usageError(usage, error))
+19
View File
@@ -0,0 +1,19 @@
param(
[Parameter(Mandatory = $true)]
[string]$Path
)
$ErrorActionPreference = 'Stop'
$item = Get-Item -LiteralPath $Path
if (-not $item.PSIsContainer -and $item.Extension -ieq '.exe') {
[pscustomobject]@{
productVersion = [string]$item.VersionInfo.ProductVersion
fileVersion = [string]$item.VersionInfo.FileVersion
productName = [string]$item.VersionInfo.ProductName
fileDescription = [string]$item.VersionInfo.FileDescription
} | ConvertTo-Json -Compress
exit 0
}
throw "Not an executable file: $Path"
+81
View File
@@ -0,0 +1,81 @@
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
DEFAULT_PRIVATE_KEY,
DEFAULT_PUBLIC_KEY,
assertOutsideRepo,
generateEd25519KeyPair,
parseOptions,
publicKeyFingerprint,
resolvePath,
runProcess,
usageError,
writeFileAtomic
} from './lib.mjs'
const usage = `Usage:
node scripts/updates/init-key.mjs [--private-key <path>] [--public-key <path>] [--force]
The private key must remain outside the repository. The default private key is:
${DEFAULT_PRIVATE_KEY}
`
function secureWindowsAcl(filename, isDirectory = false) {
if (process.platform !== 'win32') return
const identity = runProcess('whoami', []).trim()
if (!identity) throw new Error('Unable to determine the current Windows identity')
const permission = isDirectory ? `${identity}:(OI)(CI)F` : `${identity}:(F)`
runProcess('icacls', [filename, '/inheritance:r', '/grant:r', permission])
}
async function pathExists(filename) {
try {
await fsp.access(filename)
return true
} catch {
return false
}
}
async function main() {
const options = parseOptions(process.argv.slice(2), {
values: ['private-key', 'public-key'],
booleans: ['force']
})
const privateKeyPath = await assertOutsideRepo(
resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
'Private key'
)
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
if (!options.force) {
if (await pathExists(privateKeyPath)) throw new Error(`Refusing to overwrite existing private key: ${privateKeyPath}`)
if (await pathExists(publicKeyPath)) throw new Error(`Refusing to overwrite existing public key: ${publicKeyPath}`)
}
const keyPair = generateEd25519KeyPair()
const directory = path.dirname(privateKeyPath)
await fsp.mkdir(directory, { recursive: true, mode: 0o700 })
try {
await fsp.chmod(directory, 0o700)
} catch {
// Windows ACLs are applied below.
}
secureWindowsAcl(directory, true)
await writeFileAtomic(privateKeyPath, keyPair.privatePem, 0o600)
try {
await fsp.chmod(privateKeyPath, 0o600)
} catch {
// Windows ACLs are applied below.
}
secureWindowsAcl(privateKeyPath)
await writeFileAtomic(publicKeyPath, keyPair.publicPem, 0o644)
console.log(`Private key: ${privateKeyPath}`)
console.log(`Public key: ${publicKeyPath}`)
console.log(`Public key SHA-256 fingerprint: ${publicKeyFingerprint(keyPair.publicPem)}`)
console.log('Private key material was not printed. Back up the private key separately before rotating it.')
}
main().catch((error) => usageError(usage, error))
+538
View File
@@ -0,0 +1,538 @@
import crypto from 'node:crypto'
import { spawnSync } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..')
export const DEFAULT_BASE_URL = 'http://192.168.10.6/ycode/releases/'
export const DEFAULT_SSH_HOST = 'local001'
export const DEFAULT_PRIVATE_KEY = path.join(os.homedir(), '.ycode-release', 'update-signing.key')
export const DEFAULT_PUBLIC_KEY = path.join(REPO_ROOT, 'resources', 'updates', 'public-key.pem')
export const BASELINE_VERSION = '0.0.1'
export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024
export const MIN_PUBLIC_INSTALLER_BYTES = 20 * 1024 * 1024
export const SERVER_PUBLIC_ROOT = '/srv/ycode'
export const SERVER_STAGING_ROOT = '/srv/ycode-staging'
export const SERVER_RELEASES_ROOT = '/srv/ycode/releases'
export const NGINX_SITE_LINK = '/etc/nginx/sites-enabled/default'
export const NGINX_SNIPPET = '/etc/nginx/snippets/ycode-releases.conf'
export const NGINX_MARKER = '# YCode updater releases (managed by scripts/updates)'
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
const SHA256_RE = /^[a-f0-9]{64}$/
const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/
function fail(message) {
throw new Error(message)
}
export function assert(condition, message) {
if (!condition) fail(message)
}
export function parseOptions(argv, { values = [], booleans = [] } = {}) {
const valueNames = new Set(values)
const booleanNames = new Set(booleans)
const options = {}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (!argument.startsWith('--')) fail(`Unexpected argument: ${argument}`)
const equals = argument.indexOf('=')
const name = argument.slice(2, equals === -1 ? undefined : equals)
if (booleanNames.has(name)) {
assert(equals === -1, `--${name} does not accept a value`)
options[name] = true
continue
}
assert(valueNames.has(name), `Unknown option: --${name}`)
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
assert(value !== undefined && value !== '', `Missing value for --${name}`)
options[name] = value
}
return options
}
export function normalizeBaseUrl(value = DEFAULT_BASE_URL) {
let url
try {
url = new URL(value)
} catch {
fail(`Invalid base URL: ${value}`)
}
assert(url.protocol === 'http:' || url.protocol === 'https:', 'Base URL must use HTTP or HTTPS')
assert(!url.username && !url.password, 'Base URL must not contain credentials')
assert(!url.search && !url.hash, 'Base URL must not contain a query or fragment')
if (!url.pathname.endsWith('/')) url.pathname += '/'
return url.href
}
export function artifactUrlFor(baseUrl, version, filename) {
parseVersion(version)
assert(EXE_NAME_RE.test(filename), `Unsafe installer filename: ${filename}`)
const base = normalizeBaseUrl(baseUrl)
return new URL(`versions/${version}/${filename}`, base).href
}
export function assertHttpUrl(value, label = 'URL') {
let url
try {
url = new URL(value)
} catch {
fail(`${label} is invalid`)
}
assert(url.protocol === 'http:' || url.protocol === 'https:', `${label} must use HTTP or HTTPS`)
assert(!url.username && !url.password, `${label} must not contain credentials`)
assert(!url.search, `${label} must not contain a query`)
assert(!url.hash, `${label} must not contain a fragment`)
return url
}
export function parseVersion(version) {
assert(typeof version === 'string' && version.length <= 256, 'Version must be a non-empty string')
const match = SEMVER_RE.exec(version)
assert(match, `Version is not strict SemVer: ${version}`)
const prerelease = match[4] ? match[4].split('.') : []
for (const identifier of prerelease) {
assert(!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0'), `Invalid SemVer prerelease: ${version}`)
}
return {
major: BigInt(match[1]),
minor: BigInt(match[2]),
patch: BigInt(match[3]),
prerelease,
build: match[5] ? match[5].split('.') : []
}
}
function compareIdentifier(left, right) {
const leftNumeric = /^\d+$/.test(left)
const rightNumeric = /^\d+$/.test(right)
if (leftNumeric && rightNumeric) {
const a = BigInt(left)
const b = BigInt(right)
return a < b ? -1 : a > b ? 1 : 0
}
if (leftNumeric) return -1
if (rightNumeric) return 1
return left < right ? -1 : left > right ? 1 : 0
}
export function compareVersions(left, right) {
const a = parseVersion(left)
const b = parseVersion(right)
for (const key of ['major', 'minor', 'patch']) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
}
if (!a.prerelease.length && !b.prerelease.length) return 0
if (!a.prerelease.length) return 1
if (!b.prerelease.length) return -1
const length = Math.max(a.prerelease.length, b.prerelease.length)
for (let index = 0; index < length; index += 1) {
if (a.prerelease[index] === undefined) return -1
if (b.prerelease[index] === undefined) return 1
const compared = compareIdentifier(a.prerelease[index], b.prerelease[index])
if (compared !== 0) return compared
}
return 0
}
export function normalizeProductVersion(value) {
let normalized = String(value ?? '').trim()
if (normalized.startsWith('v')) normalized = normalized.slice(1)
const windowsRevision = /^(\d+\.\d+\.\d+)\.0$/.exec(normalized)
if (windowsRevision) normalized = windowsRevision[1]
return normalized
}
export async function sha256File(filename) {
const hash = crypto.createHash('sha256')
for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
return hash.digest('hex')
}
export async function readJson(filename) {
try {
return JSON.parse(await fsp.readFile(filename, 'utf8'))
} catch (error) {
fail(`Unable to read JSON ${filename}: ${error.message}`)
}
}
export async function writeFileAtomic(filename, data, mode = 0o644) {
const directory = path.dirname(filename)
await fsp.mkdir(directory, { recursive: true })
const temporary = path.join(directory, `.${path.basename(filename)}.${crypto.randomBytes(8).toString('hex')}.tmp`)
const handle = await fsp.open(temporary, 'wx', mode)
try {
await handle.writeFile(data)
await handle.sync()
} finally {
await handle.close()
}
try {
await fsp.rename(temporary, filename)
} catch (error) {
await fsp.rm(temporary, { force: true })
throw error
}
}
export function resolvePath(value, base = process.cwd()) {
return path.resolve(base, value)
}
export async function assertOutsideRepo(filename, label) {
const candidate = path.resolve(filename)
const repo = path.resolve(REPO_ROOT)
const relative = path.relative(repo, candidate)
assert(relative.startsWith('..') || path.isAbsolute(relative), `${label} must be outside the repository: ${candidate}`)
return candidate
}
export function generateEd25519KeyPair() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
return {
privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString()
}
}
export function createPrivateKey(privateKeyPem) {
const key = crypto.createPrivateKey(privateKeyPem)
assert(key.asymmetricKeyType === 'ed25519', 'Update signing key must be Ed25519')
return key
}
export function createPublicKey(publicKeyPem) {
const key = crypto.createPublicKey(publicKeyPem)
assert(key.asymmetricKeyType === 'ed25519', 'Update public key must be Ed25519')
return key
}
export function signPayload(payload, privateKeyPem) {
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
return crypto.sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64')
}
export function verifyPayloadSignature(payload, signature, publicKeyPem) {
assert(typeof signature === 'string', 'Manifest signature must be a string')
const signatureBytes = Buffer.from(signature, 'base64')
assert(signatureBytes.length === 64, 'Manifest signature must contain 64 bytes')
assert(signatureBytes.toString('base64') === signature, 'Manifest signature must use canonical base64')
const bytes = Buffer.from(JSON.stringify(payload), 'utf8')
return crypto.verify(null, bytes, createPublicKey(publicKeyPem), signatureBytes)
}
export function publicKeyFingerprint(publicKeyPem) {
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' })
return crypto.createHash('sha256').update(der).digest('hex')
}
function assertExactKeys(value, expected, label) {
assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`)
const actual = Object.keys(value)
assert(actual.length === expected.length && expected.every((key) => actual.includes(key)), `${label} has unexpected or missing fields`)
}
export function validateManifestDocument(document, { baseUrl = DEFAULT_BASE_URL } = {}) {
assertExactKeys(document, ['payload', 'signature'], 'Manifest')
const payload = document.payload
assertExactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], 'Manifest payload')
assert(payload.schemaVersion === 1, 'Unsupported update manifest schemaVersion')
parseVersion(payload.version)
assert(typeof payload.publishedAt === 'string', 'publishedAt must be a string')
const published = new Date(payload.publishedAt)
assert(!Number.isNaN(published.valueOf()), 'publishedAt must be an ISO timestamp')
assert(published.toISOString() === payload.publishedAt, 'publishedAt must be canonical UTC ISO')
assertExactKeys(payload.notes, ['zh', 'en'], 'Manifest notes')
for (const language of ['zh', 'en']) {
assert(typeof payload.notes[language] === 'string' && payload.notes[language].trim(), `Manifest notes.${language} is required`)
assert(payload.notes[language].length <= 16_384, `Manifest notes.${language} is too long`)
}
assertExactKeys(payload.platforms, ['windows-x64'], 'Manifest platforms')
const platform = payload.platforms['windows-x64']
assertExactKeys(platform, ['url', 'size', 'sha256'], 'windows-x64 platform')
assert(Number.isSafeInteger(platform.size) && platform.size > 0 && platform.size <= MAX_INSTALLER_BYTES, 'windows-x64 size is invalid')
assert(typeof platform.sha256 === 'string' && SHA256_RE.test(platform.sha256), 'windows-x64 sha256 must be lowercase SHA-256 hex')
const url = assertHttpUrl(platform.url, 'windows-x64 URL')
const filename = path.posix.basename(url.pathname)
const expected = artifactUrlFor(baseUrl, payload.version, filename)
assert(url.href === expected, 'windows-x64 URL is outside the approved version path')
return payload
}
export function verifySignedManifest(document, publicKeyPem, options = {}) {
const payload = validateManifestDocument(document, options)
assert(verifyPayloadSignature(payload, document.signature, publicKeyPem), 'Update manifest signature is invalid')
return payload
}
export function serializeSignedManifest(document, options = {}) {
validateManifestDocument(document, options)
return `${JSON.stringify(document)}\n`
}
export async function readPublicKey(filename = DEFAULT_PUBLIC_KEY) {
try {
return await fsp.readFile(filename, 'utf8')
} catch (error) {
fail(`Unable to read update public key ${filename}: ${error.message}`)
}
}
export async function readPrivateKey(filename = DEFAULT_PRIVATE_KEY) {
try {
return await fsp.readFile(filename, 'utf8')
} catch (error) {
fail(`Unable to read update signing key ${filename}: ${error.message}`)
}
}
export function runProcess(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
input: options.input,
encoding: 'utf8',
windowsHide: true,
maxBuffer: 8 * 1024 * 1024
})
if (result.error) fail(`Unable to run ${command}: ${result.error.message}`)
if (result.status !== 0) {
const detail = String(result.stderr || result.stdout || '').trim()
fail(`${command} failed with exit code ${result.status}${detail ? `: ${detail}` : ''}`)
}
return result.stdout
}
export function validateSshHost(host = DEFAULT_SSH_HOST) {
assert(/^[A-Za-z0-9_.@:-]+$/.test(host) && !host.startsWith('-'), 'SSH host contains unsafe characters')
return host
}
export function runSsh(host, script) {
return runProcess('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', validateSshHost(host), 'bash', '-s'], {
input: script
})
}
export async function getProductVersion(installerPath) {
const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'get-exe-version.ps1')
assert(fs.existsSync(script), `Missing version helper: ${script}`)
const output = runProcess('powershell.exe', [
'-NoLogo',
'-NoProfile',
'-NonInteractive',
'-ExecutionPolicy',
'Bypass',
'-File',
script,
'-Path',
installerPath
])
let parsed
try {
parsed = JSON.parse(output)
} catch {
fail('Unable to parse installer version information')
}
assert(parsed && typeof parsed.productVersion === 'string', 'Installer has no ProductVersion')
return parsed
}
export async function readPackageMetadata() {
const packagePath = path.join(REPO_ROOT, 'package.json')
const pkg = await readJson(packagePath)
assert(pkg.name === 'ycode', 'package.json is not the YCode package')
assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
parseVersion(pkg.version)
return { path: packagePath, package: pkg }
}
export async function verifyReleaseInstaller(installerPath, version, { minimumSize = MIN_PUBLIC_INSTALLER_BYTES } = {}) {
parseVersion(version)
assert(compareVersions(version, BASELINE_VERSION) > 0, `Release version must be newer than the updater-less ${BASELINE_VERSION} baseline`)
assert(EXE_NAME_RE.test(path.basename(installerPath)), `Unsafe installer filename: ${path.basename(installerPath)}`)
let resolved
try {
resolved = await fsp.realpath(installerPath)
} catch (error) {
fail(`Unable to resolve installer: ${error.message}`)
}
const stat = await fsp.stat(resolved)
assert(stat.isFile(), 'Installer must be a regular file')
assert(stat.size >= minimumSize, `Installer is unexpectedly small (${stat.size} bytes)`)
assert(stat.size <= MAX_INSTALLER_BYTES, 'Installer exceeds the 4 GiB limit')
const product = await getProductVersion(resolved)
const productVersion = normalizeProductVersion(product.productVersion)
assert(productVersion === version, `Installer ProductVersion ${product.productVersion} does not match ${version}`)
const { path: packagePath, package: pkg } = await readPackageMetadata()
assert(normalizeProductVersion(pkg.version) === version, `package.json version ${pkg.version} does not match ${version}`)
return {
path: resolved,
filename: path.basename(resolved),
size: stat.size,
sha256: await sha256File(resolved),
productVersion,
packagePath
}
}
export function buildManifestPayload({ version, publishedAt, notes, installer, baseUrl = DEFAULT_BASE_URL }) {
const url = artifactUrlFor(baseUrl, version, installer.filename)
return {
schemaVersion: 1,
version,
publishedAt,
notes: {
zh: notes.zh,
en: notes.en
},
platforms: {
'windows-x64': {
url,
size: installer.size,
sha256: installer.sha256
}
}
}
}
export function createSignedManifest({ payload, privateKeyPem, publicKeyPem, baseUrl = DEFAULT_BASE_URL }) {
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, { baseUrl })
const signature = signPayload(payload, privateKeyPem)
const document = { payload, signature }
assert(verifyPayloadSignature(payload, signature, publicKeyPem), 'Generated manifest does not verify with the configured public key')
validateManifestDocument(document, { baseUrl })
return document
}
export function canonicalNow(value = new Date()) {
return value.toISOString()
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function formatBytes(value) {
return `${(value / (1024 * 1024)).toFixed(1)} MiB (${value.toLocaleString('en-US')} bytes)`
}
export function renderDownloadPage(payload, options = {}) {
validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, options)
const platform = payload.platforms['windows-x64']
const version = escapeHtml(payload.version)
const url = escapeHtml(platform.url)
const sha256 = escapeHtml(platform.sha256)
const publishedAt = escapeHtml(payload.publishedAt)
const zhNotes = escapeHtml(payload.notes.zh)
const enNotes = escapeHtml(payload.notes.en)
const size = escapeHtml(formatBytes(platform.size))
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta name="referrer" content="no-referrer">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>YCode v${version} 下载</title>
<style>
:root { color-scheme: light; --ink:#17201b; --muted:#667069; --paper:#f4f1e8; --card:#fffdf7; --line:#d9d4c6; --accent:#176b4d; --accent-dark:#0d4d36; }
* { box-sizing: border-box; }
body { margin:0; min-height:100vh; background:radial-gradient(circle at top left, #e2eadf 0, transparent 34rem), var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI","Noto Sans CJK SC",sans-serif; }
main { width:min(760px, calc(100% - 32px)); margin:0 auto; padding:clamp(48px,9vw,96px) 0; }
.eyebrow { margin:0 0 16px; color:var(--accent-dark); font-size:12px; font-weight:700; letter-spacing:.18em; text-transform:uppercase; }
h1 { margin:0; font-size:clamp(36px,7vw,64px); line-height:1; letter-spacing:-.045em; }
.summary { margin:18px 0 30px; color:var(--muted); font-size:16px; line-height:1.7; }
.card { border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); overflow:hidden; }
.release { padding:clamp(22px,4vw,34px); border-bottom:1px solid var(--line); }
.meta { display:flex; flex-wrap:wrap; gap:8px 18px; margin:0 0 22px; color:var(--muted); font-size:13px; }
.download { display:inline-flex; align-items:center; justify-content:center; min-height:46px; padding:0 22px; border-radius:999px; background:var(--accent); color:white; font-weight:700; text-decoration:none; }
.download:hover { background:var(--accent-dark); }
.hash { display:block; overflow-wrap:anywhere; margin-top:16px; padding:12px 14px; border:1px solid var(--line); border-radius:10px; background:#f8f6ef; color:#39443d; font:12px/1.6 ui-monospace,SFMono-Regular,Consolas,monospace; }
.notes { padding:clamp(22px,4vw,34px); display:grid; gap:24px; }
.notes h2 { margin:0 0 8px; font-size:14px; letter-spacing:.04em; }
.notes p { margin:0; color:#3e4841; line-height:1.75; white-space:pre-wrap; }
.footer { margin-top:20px; color:var(--muted); font-size:12px; line-height:1.7; }
code { font-family:ui-monospace,SFMono-Regular,Consolas,monospace; }
</style>
</head>
<body>
<main>
<p class="eyebrow">YCode / Windows x64</p>
<h1>v${version}</h1>
<p class="summary">此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。</p>
<section class="card">
<div class="release">
<div class="meta">
<span>发布时间 ${publishedAt}</span>
<span>${size}</span>
</div>
<a class="download" href="${url}" rel="noopener">下载 Windows x64 安装包</a>
<code class="hash">SHA-256 ${sha256}</code>
</div>
<div class="notes">
<div>
<h2>更新说明</h2>
<p lang="zh-CN">${zhNotes}</p>
</div>
<div>
<h2>Release notes</h2>
<p lang="en">${enNotes}</p>
</div>
</div>
</section>
<p class="footer">请核对安装包 SHA-256。更新客户端会独立验证签名、文件大小和哈希。<br>
<a href="latest.json">查看签名清单</a></p>
</main>
</body>
</html>
`
}
export function renderNoReleasePage() {
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'">
<title>YCode 下载</title>
<style>
:root { --paper:#f4f1e8; --card:#fffdf7; --ink:#17201b; --muted:#667069; --line:#d9d4c6; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:24px; background:var(--paper); color:var(--ink); font-family:"Segoe UI Variable","Microsoft YaHei UI",sans-serif; }
main { width:min(560px,100%); padding:40px; border:1px solid var(--line); border-radius:18px; background:var(--card); box-shadow:0 24px 70px rgba(24,43,34,.08); }
h1 { margin:0 0 12px; font-size:clamp(32px,8vw,52px); letter-spacing:-.04em; }
p { margin:0; color:var(--muted); line-height:1.75; }
</style>
</head>
<body data-ycode-state="no-release"><main><h1>YCode</h1><p>当前还没有发布正式安装包。此静态页不加载脚本、不提供占位版本;发布完成后再刷新。</p></main></body>
</html>
`
}
export function usageError(usage, error) {
console.error(`${error.message}\n\n${usage}`)
process.exitCode = 2
}
@@ -0,0 +1,35 @@
import assert from 'node:assert/strict'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { REPO_ROOT } from './lib.mjs'
const filename = path.join(REPO_ROOT, 'build', 'installer.nsh')
const source = await fsp.readFile(filename, 'utf8')
assert.match(source, /OpenProcess\(i 0x100000, i 0, i R1\) p \.R2 \?e/)
assert.match(source, /Pop \$R3/)
assert.doesNotMatch(source, /GetLastError\(\)/)
assert.match(source, /WaitForSingleObject\(p R2, i 120000\)/)
assert.match(source, /ElseIf\} \$R3 != 87/)
const waitIndex = source.indexOf('WaitForSingleObject')
const imageCheckIndex = source.indexOf('SetEnvironmentVariable(t "YCODE_UPDATE_TARGET_DIR"')
assert.ok(waitIndex >= 0 && imageCheckIndex > waitIndex, 'image check must run after waiting for the parent PID')
assert.ok(imageCheckIndex > source.indexOf('${ElseIf} $R3 != 87'), 'image check must run after the OpenProcess branch join')
assert.ok(source.includes('t "$INSTDIR\\${APP_EXECUTABLE_FILENAME}"'))
assert.match(source, /nsExec::ExecToStack/)
assert.match(source, /Get-CimInstance Win32_Process/)
assert.match(source, /TrimEnd\(\[char\]92\)/)
assert.match(source, /AddSeconds\(5\)/)
assert.match(source, /Start-Sleep -Milliseconds 500/)
assert.match(source, /if \(-not \$\$process\.ExecutablePath\) \{ \$\$found=\$\$true; continue \}/)
assert.match(source, /Name -ieq '\$\{APP_EXECUTABLE_FILENAME\}'/)
assert.match(source, /StrCmp \$R4 "0" \+3/)
assert.doesNotMatch(source, /KillProcess|taskkill|Stop-Process|_CHECK_APP_RUNNING/)
assert.doesNotMatch(source, /GetProcessInfo/)
assert.doesNotMatch(source, /GetLastError\(\)/)
assert.doesNotMatch(source, /Name -ieq 'YCode\.exe'/)
console.log('NSIS updater safety checks passed.')
+71
View File
@@ -0,0 +1,71 @@
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
DEFAULT_BASE_URL,
DEFAULT_PRIVATE_KEY,
DEFAULT_PUBLIC_KEY,
assertOutsideRepo,
buildManifestPayload,
canonicalNow,
createSignedManifest,
normalizeBaseUrl,
parseOptions,
readPrivateKey,
readPublicKey,
resolvePath,
serializeSignedManifest,
usageError,
verifyReleaseInstaller,
verifySignedManifest,
writeFileAtomic
} from './lib.mjs'
const usage = `Usage:
node scripts/updates/prepare-release.mjs --installer <YCode-Setup.exe> --version <x.y.z>
--notes-zh <text> --notes-en <text> [--base-url <url>] [--published-at <ISO>]
[--private-key <path>] [--public-key <path>] [--output <latest.json>]
This validates the actual EXE ProductVersion, current package.json version, size and SHA-256,
then signs exact JSON.stringify(payload) with Ed25519. It never deploys.
`
async function main() {
const options = parseOptions(process.argv.slice(2), {
values: ['installer', 'version', 'notes-zh', 'notes-en', 'base-url', 'published-at', 'private-key', 'public-key', 'output']
})
if (!options.installer || !options.version || !options['notes-zh'] || !options['notes-en']) {
throw new Error('--installer, --version, --notes-zh and --notes-en are required')
}
const baseUrl = normalizeBaseUrl(options['base-url'] || DEFAULT_BASE_URL)
const installerPath = resolvePath(options.installer)
const installer = await verifyReleaseInstaller(installerPath, options.version)
const output = resolvePath(options.output || path.join('.tmp', 'updates', options.version, 'latest.json'))
const privateKeyPath = await assertOutsideRepo(
resolvePath(options['private-key'] || DEFAULT_PRIVATE_KEY),
'Private key'
)
const privateKey = await readPrivateKey(privateKeyPath)
const publicKeyPath = resolvePath(options['public-key'] || DEFAULT_PUBLIC_KEY)
const publicKey = await readPublicKey(publicKeyPath)
const payload = buildManifestPayload({
version: options.version,
publishedAt: options['published-at'] || canonicalNow(),
notes: { zh: options['notes-zh'], en: options['notes-en'] },
installer,
baseUrl
})
const document = createSignedManifest({ payload, privateKeyPem: privateKey, publicKeyPem: publicKey, baseUrl })
verifySignedManifest(document, publicKey, { baseUrl })
await writeFileAtomic(output, serializeSignedManifest(document))
await fsp.access(output)
console.log(`Manifest: ${output}`)
console.log(`Version: ${payload.version}`)
console.log(`Installer: ${installer.filename}`)
console.log(`Size: ${installer.size}`)
console.log(`SHA-256: ${installer.sha256}`)
console.log(`URL: ${payload.platforms['windows-x64'].url}`)
}
main().catch((error) => usageError(usage, error))
+234
View File
@@ -0,0 +1,234 @@
import {
DEFAULT_SSH_HOST,
NGINX_MARKER,
NGINX_SITE_LINK,
NGINX_SNIPPET,
SERVER_PUBLIC_ROOT,
SERVER_RELEASES_ROOT,
SERVER_STAGING_ROOT,
parseOptions,
renderNoReleasePage,
runSsh,
usageError,
validateSshHost
} from './lib.mjs'
const usage = `Usage:
node scripts/updates/provision-server.mjs [--ssh-host local001]
Creates /srv/ycode, /srv/ycode/releases/versions and /srv/ycode-staging, backs up the
nginx site configuration, installs a YCode-only snippet, validates nginx, then reloads.
It never serves an index and never creates a fake latest.json.
`
const snippet = `${NGINX_MARKER}
location = /ycode/releases/latest.json {
alias ${SERVER_RELEASES_ROOT}/latest.json;
default_type application/json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
limit_except GET { deny all; }
}
location = /ycode/releases/index.html {
alias ${SERVER_RELEASES_ROOT}/index.html;
default_type text/html;
charset utf-8;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
limit_except GET { deny all; }
}
location /ycode/ {
alias ${SERVER_PUBLIC_ROOT}/;
autoindex off;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
limit_except GET { deny all; }
}
`
async function main() {
const options = parseOptions(process.argv.slice(2), { values: ['ssh-host'] })
const host = validateSshHost(options['ssh-host'] || DEFAULT_SSH_HOST)
const snippetBase64 = Buffer.from(snippet, 'utf8').toString('base64')
const noReleasePageBase64 = Buffer.from(renderNoReleasePage(), 'utf8').toString('base64')
const script = `set -euo pipefail
export LC_ALL=C
site_link='${NGINX_SITE_LINK}'
snippet='${NGINX_SNIPPET}'
site="$(readlink -f "$site_link")"
test -f "$site"
test -f /etc/nginx/nginx.conf
nginx -t >/dev/null 2>&1
install -d -o root -g www-data -m 0755 '${SERVER_PUBLIC_ROOT}'
install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}'
install -d -o root -g www-data -m 0755 '${SERVER_RELEASES_ROOT}/versions'
install -d -o root -g root -m 0700 '${SERVER_STAGING_ROOT}'
page_tmp="$(mktemp '${SERVER_RELEASES_ROOT}/.index.html.XXXXXX')"
printf '%s' '${noReleasePageBase64}' | base64 -d > "$page_tmp"
chmod 0644 "$page_tmp"
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
if [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
rm -f "$page_tmp"
echo "latest.json exists without a generated download page" >&2
exit 1
fi
if grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
rm -f "$page_tmp"
echo "latest.json exists while the download page still says no release" >&2
exit 1
fi
rm -f "$page_tmp"
elif [ ! -f '${SERVER_RELEASES_ROOT}/index.html' ]; then
mv -f "$page_tmp" '${SERVER_RELEASES_ROOT}/index.html'
else
if ! grep -q 'data-ycode-state="no-release"' '${SERVER_RELEASES_ROOT}/index.html'; then
rm -f "$page_tmp"
echo "download page exists without latest.json" >&2
exit 1
fi
rm -f "$page_tmp"
fi
backup_dir=/var/backups/nginx
install -d -o root -g root -m 0700 "$backup_dir"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
config_backup="$backup_dir/ycode-before-${'$'}{stamp}-$(basename "$site")"
snippet_backup="$backup_dir/ycode-before-${'$'}{stamp}-ycode-releases.conf"
test ! -e "$config_backup"
cp -a "$site" "$config_backup"
snippet_existed=0
if [ -f "$snippet" ]; then
cp -a "$snippet" "$snippet_backup"
snippet_existed=1
fi
restore() {
changed=0
cp -a "$config_backup" "$site"
if [ "$snippet_existed" -eq 1 ]; then
cp -a "$snippet_backup" "$snippet"
else
rm -f "$snippet"
fi
}
changed=0
snippet_tmp=
cleanup() {
rc=$?
if [ -n "$snippet_tmp" ]; then
rm -f "$snippet_tmp"
fi
if [ "$rc" -ne 0 ] && [ "$changed" -eq 1 ]; then
restore
nginx -t >/dev/null 2>&1 && systemctl reload nginx >/dev/null 2>&1 || true
fi
exit "$rc"
}
trap cleanup EXIT
install -d -o root -g root -m 0755 /etc/nginx/snippets
snippet_tmp="$(mktemp /etc/nginx/snippets/.ycode-releases.XXXXXX)"
printf '%s' '${snippetBase64}' | base64 -d > "$snippet_tmp"
chmod 0644 "$snippet_tmp"
mv -f "$snippet_tmp" "$snippet"
changed=1
python3 - "$site" <<'PY'
import os
import re
import shutil
import sys
import tempfile
site = sys.argv[1]
marker = "${NGINX_MARKER}"
include = "\tinclude ${NGINX_SNIPPET};"
with open(site, "r", encoding="utf-8") as stream:
original = stream.read()
for required in ("location /baocode/ {", "location /baocode/releases/ {"):
if original.count(required) != 1:
raise SystemExit(f"refusing to change nginx: expected one {required}")
if marker in original:
if include not in original:
raise SystemExit("refusing to change nginx: YCode marker exists without include")
updated = original
else:
match = re.search(r"(?m)^([ \\t]*)location /baocode/ \\{", original)
if not match:
raise SystemExit("refusing to change nginx: baocode anchor not found")
indent = match.group(1)
insertion = f"{indent}{marker}\\n{indent}include ${NGINX_SNIPPET};\\n\\n"
updated = original[:match.start()] + insertion + original[match.start():]
if updated != original:
directory = os.path.dirname(site)
descriptor, temporary = tempfile.mkstemp(prefix=".ycode-nginx-", dir=directory)
try:
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
stream.write(updated)
stream.flush()
os.fsync(stream.fileno())
shutil.copystat(site, temporary)
os.replace(temporary, site)
finally:
if os.path.exists(temporary):
os.unlink(temporary)
with open(site, "r", encoding="utf-8") as stream:
final = stream.read()
for required in ("location /baocode/ {", "location /baocode/releases/ {"):
if final.count(required) != 1:
raise SystemExit("refusing to change nginx: baocode locations changed")
if marker not in final or include not in final:
raise SystemExit("nginx include was not installed")
PY
if ! nginx -t; then
echo "nginx validation failed; configuration restored" >&2
exit 1
fi
if ! systemctl reload nginx; then
echo "nginx reload failed; configuration restored" >&2
exit 1
fi
status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/latest.json || true)"
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
expected_status=200
else
expected_status=404
fi
if [ "$status" != "$expected_status" ]; then
echo "unexpected /ycode/releases/latest.json status: $status (expected $expected_status)" >&2
exit 1
fi
page_status="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1/ycode/releases/ || true)"
if [ "$page_status" != "200" ]; then
echo "unexpected /ycode/releases/ status: $page_status (expected 200)" >&2
exit 1
fi
printf 'site=%s\\n' "$site"
printf 'backup=%s\\n' "$config_backup"
printf 'snippet=%s\\n' "$snippet"
printf 'latest_status=%s\\n' "$status"
printf 'page_status=%s\\n' "$page_status"
`
const output = runSsh(host, script)
process.stdout.write(output)
console.log(`Provisioned ${host}; no release manifest was created.`)
}
main().catch((error) => usageError(usage, error))
+641
View File
@@ -0,0 +1,641 @@
import assert from 'node:assert/strict'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {
buildManifestPayload,
createSignedManifest,
generateEd25519KeyPair,
normalizeBaseUrl,
serializeSignedManifest,
writeFileAtomic
} from './lib.mjs'
import {
acquireReleaseLock,
defaultReleaseNotes,
finalizeReleaseRequest,
parseReleaseArgs,
releasePaths,
resolveVersionChoice,
runReleaseWorkflow,
updateLockText,
updatePackageText,
verifyPublishedManifest
} from './release-workflow.mjs'
let passed = 0
async function test(name, callback) {
await callback()
passed += 1
console.log(`PASS ${name}`)
}
function expectedError(pattern) {
return (error) => {
assert.match(String(error?.message || error), pattern)
return true
}
}
function packageText(version) {
return `${JSON.stringify({
name: 'ycode',
version,
private: true,
scripts: { build: 'fixture-build' },
build: { appId: 'com.ycode.app', productName: 'YCode' }
}, null, 2)}\n`
}
function lockText(version) {
return `${JSON.stringify({
name: 'ycode',
version,
lockfileVersion: 3,
requires: true,
packages: {
'': {
name: 'ycode',
version,
private: true,
dependencies: { fixture: '1.0.0' }
},
'node_modules/fixture': {
version: '1.0.0'
}
}
}, null, 2)}\n`
}
function signedManifest({ version, keys, baseUrl }) {
const payload = buildManifestPayload({
version,
publishedAt: '2026-10-20T10:00:00.000Z',
notes: defaultReleaseNotes(version),
installer: {
filename: `YCode-${version}-Setup-x64.exe`,
size: 1,
sha256: 'a'.repeat(64)
},
baseUrl
})
const document = createSignedManifest({
payload,
privateKeyPem: keys.privatePem,
publicKeyPem: keys.publicPem,
baseUrl
})
return {
document,
text: serializeSignedManifest(document, { baseUrl })
}
}
async function makeRepository({ version = '1.0.0' } = {}) {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-selftest-'))
await fsp.writeFile(path.join(root, 'package.json'), packageText(version), 'utf8')
await fsp.writeFile(path.join(root, 'package-lock.json'), lockText(version), 'utf8')
return root
}
function requestFor({ version, notes, privateKeyPath, publicKeyPath, resume = false }) {
return {
help: false,
resume,
version,
notes: notes || defaultReleaseNotes(version),
sshHost: 'fake-local001',
baseUrl: normalizeBaseUrl('http://127.0.0.1/ycode/releases/'),
privateKeyPath,
publicKeyPath
}
}
function makeDependencies({
root,
keys,
current,
events,
overrides = {}
}) {
const paths = releasePaths(root, current?.version || '9.9.9')
const deps = {
root,
pathExists: (filename) => fsp.access(filename).then(() => true, () => false),
realpath: (filename) => fsp.realpath(filename),
readFile: (filename, encoding) => fsp.readFile(filename, encoding),
writeFileAtomic,
readPrivateKey: async () => keys.privatePem,
readPublicKey: async () => keys.publicPem,
validateBundle: async ({ manifestPath }) => {
events.push('bundle-validate')
const document = JSON.parse(await fsp.readFile(manifestPath, 'utf8'))
return { document }
},
acquireReleaseLock: async () => {
events.push('lock-acquire')
return async () => {
events.push('lock-release')
}
},
checkToolchain: async () => {
events.push('toolchain-check')
},
checkSsh: async () => {
events.push('ssh-check')
},
readLatest: async () => {
events.push('server-read')
return current ? { raw: current.text, document: current.document } : { raw: null, document: null }
},
runNpm: async (args) => {
events.push(`npm:${args.join(' ')}`)
},
runNode: async (args) => {
events.push(`node:${args.join(' ')}`)
if (args.some((value) => String(value).endsWith('prepare-release.mjs'))) {
const version = args[args.indexOf('--version') + 1]
const output = args[args.indexOf('--output') + 1]
await writeFileAtomic(output, signedManifest({ version, keys, baseUrl: 'http://127.0.0.1/ycode/releases/' }).text)
}
},
verifyHttp: async () => {
events.push('http-verify')
},
logWarning: (message) => events.push(`warning:${message}`),
...overrides
}
deps.paths = paths
return deps
}
async function main() {
const temporaryDirectories = []
let signingKeyProbePath
try {
const keyProbeDirectory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-key-probe-'))
temporaryDirectories.push(keyProbeDirectory)
signingKeyProbePath = path.join(keyProbeDirectory, 'signing.key')
await fsp.writeFile(signingKeyProbePath, 'fixture key location only', 'utf8')
await test('stable version input rejects ambiguous and non-release versions', async () => {
for (const value of ['01.0.0', '1.01.0', '1.0.01', '1.0.0-rc.1', '1.0.0+build', 'v1.0.0', '1.0']) {
await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /stable x\.y\.z/)
}
for (const value of ['65536.0.0', '1.65536.0', '1.0.65536']) {
await assert.rejects(() => resolveVersionChoice({ version: value, prompt: async () => '' }), /Windows EXE metadata/)
}
assert.equal(
await resolveVersionChoice({ version: '1.2.3', prompt: async () => assert.fail('prompt should not run') }),
'1.2.3'
)
})
await test('blank interactive version cancels without starting a release', async () => {
assert.equal(await resolveVersionChoice({ prompt: async () => ' ' }), null)
})
await test('CLI defaults and unknown option handling are deterministic', async () => {
const parsed = parseReleaseArgs(['--version', '1.2.3'])
assert.equal(parsed.version, '1.2.3')
assert.deepEqual(parsed.notes, {})
const finalized = finalizeReleaseRequest(parsed, parsed.version)
assert.deepEqual(finalized.notes, {
zh: 'YCode 1.2.3 更新',
en: 'YCode 1.2.3 update'
})
assert.equal(parsed.sshHost, 'local001')
await assert.rejects(async () => parseReleaseArgs(['--dry-run']), /Unknown option/)
await assert.rejects(async () => parseReleaseArgs(['--public-key', 'other.pem']), /Unknown option/)
await assert.rejects(async () => parseReleaseArgs(['--resume']), /requires an explicit --version/)
assert.equal(parseReleaseArgs(['--help']).help, true)
})
await test('interactive version supplies notes from the actual input version', async () => {
const parsed = parseReleaseArgs([])
const version = await resolveVersionChoice({ version: parsed.version, prompt: async () => '0.0.6' })
const finalized = finalizeReleaseRequest(parsed, version)
assert.deepEqual(finalized.notes, {
zh: 'YCode 0.0.6 更新',
en: 'YCode 0.0.6 update'
})
})
await test('package and lock version updates preserve unrelated JSON content', async () => {
const nextPackage = updatePackageText(packageText('1.0.0'), '1.0.1')
const nextLock = updateLockText(lockText('1.0.0'), '1.0.1')
const pkg = JSON.parse(nextPackage)
const lock = JSON.parse(nextLock)
assert.equal(pkg.version, '1.0.1')
assert.equal(pkg.scripts.build, 'fixture-build')
assert.equal(pkg.build.appId, 'com.ycode.app')
assert.equal(lock.version, '1.0.1')
assert.equal(lock.packages[''].version, '1.0.1')
assert.equal(lock.packages['node_modules/fixture'].version, '1.0.0')
})
const normalRoot = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(normalRoot)
await test('normal release follows the required stage and command order', async () => {
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const current = signedManifest({ version: '1.0.0', keys, baseUrl })
const events = []
const deps = makeDependencies({
root: normalRoot,
keys,
current: { ...current, version: '1.0.0' },
events
})
const request = requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
})
const result = await runReleaseWorkflow(request, deps)
assert.equal(result.version, '1.0.1')
const paths = releasePaths(normalRoot, '1.0.1')
assert.deepEqual(events.slice(0, 7), [
'lock-acquire',
'toolchain-check',
'ssh-check',
'server-read',
'npm:run test:updates',
'npm:run build',
`node:${paths.builderCli} --win nsis --x64 --publish never --config.directories.output=release/publish/1.0.1`
])
assert.match(events[7], /prepare-release\.mjs/)
assert.match(events[8], /deploy\.mjs.*--dry-run/)
assert.match(events[9], /deploy\.mjs/)
assert.doesNotMatch(events[9], /--dry-run/)
assert.deepEqual(events.slice(10), ['http-verify', 'lock-release'])
assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package.json'), 'utf8')).version, '1.0.1')
assert.equal(JSON.parse(await fsp.readFile(path.join(normalRoot, 'package-lock.json'), 'utf8')).version, '1.0.1')
})
await test('server/latest and repository versions are rejected before build', async () => {
for (const scenario of [
{ repo: '1.0.0', server: '1.0.0', target: '1.0.0', pattern: /strictly newer/ },
{ repo: '1.0.0', server: '0.9.0', target: '0.9.9', pattern: /repository version is newer/ }
]) {
const root = await makeRepository({ version: scenario.repo })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const server = signedManifest({ version: scenario.server, keys, baseUrl })
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...server, version: scenario.server },
events
})
const before = await fsp.readFile(path.join(root, 'package.json'), 'utf8')
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: scenario.target,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
expectedError(scenario.pattern)
)
assert.equal(await fsp.readFile(path.join(root, 'package.json'), 'utf8'), before)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
assert.equal(events.at(-1), 'lock-release')
}
})
await test('missing signing key fails in preflight without build or deploy', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events,
overrides: {
pathExists: async (filename) =>
filename === path.join(os.tmpdir(), 'missing-signing.key')
? false
: fsp.access(filename).then(() => true, () => false),
readPrivateKey: async () => {
throw new Error('Unable to read update signing key')
}
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: path.join(os.tmpdir(), 'missing-signing.key'),
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /Signing key/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
assert.equal(events.at(-1), 'lock-release')
})
await test('mismatched private and public keys fail before any external stage', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const privateKeys = generateEd25519KeyPair()
const publicKeys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys: privateKeys,
current: null,
events,
overrides: {
readPublicKey: async () => publicKeys.publicPem
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /does not match/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
assert.equal(events.at(-1), 'lock-release')
})
await test('private key canonical path cannot resolve into the repository', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events,
overrides: {
realpath: async () => path.join(root, 'linked-signing.key')
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /resolves inside the repository/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:')))
assert.equal(events.at(-1), 'lock-release')
})
await test('a repository path beginning with ..foo is not treated as external', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events
})
const nestedKey = path.join(root, '..foo', 'signing.key')
await fsp.mkdir(path.dirname(nestedKey), { recursive: true })
await fsp.writeFile(nestedKey, 'fixture inside-repository key', 'utf8')
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: nestedKey,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /must be outside the repository/.test(error.message)
)
assert(!events.some((event) => event.startsWith('npm:') || event.startsWith('node:') || event === 'ssh-check'))
assert.equal(events.at(-1), 'lock-release')
})
await test('a failed build stops before signing and deploy while retaining local version', async () => {
const root = await makeRepository({ version: '1.0.0' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const current = signedManifest({ version: '1.0.0', keys, baseUrl })
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...current, version: '1.0.0' },
events,
overrides: {
runNpm: async (args) => {
events.push(`npm:${args.join(' ')}`)
if (args.join(' ') === 'run build') throw new Error('fixture build failure')
}
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'build' && /fixture build failure/.test(error.message)
)
assert(!events.some((event) => event.includes('prepare-release') || event.includes('deploy.mjs')))
assert.equal(JSON.parse(await fsp.readFile(path.join(root, 'package.json'), 'utf8')).version, '1.0.1')
assert.equal(events.at(-1), 'lock-release')
})
await test('resume revalidates the existing bundle and never rebuilds or re-signs', async () => {
const root = await makeRepository({ version: '1.0.1' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
const paths = releasePaths(root, '1.0.1')
await fsp.mkdir(paths.outputDir, { recursive: true })
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...local, version: '1.0.1' },
events
})
await runReleaseWorkflow(
requestFor({
version: '1.0.1',
resume: true,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
)
assert(events.includes('bundle-validate'))
assert(!events.some((event) => event.startsWith('npm:') || event.includes('prepare-release')))
assert.equal(events.filter((event) => event.includes('deploy.mjs')).length, 2)
assert.equal(events.at(-2), 'http-verify')
assert.equal(events.at(-1), 'lock-release')
})
await test('HTTP verification failure is reported as a failed release', async () => {
const root = await makeRepository({ version: '1.0.1' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
const paths = releasePaths(root, '1.0.1')
await fsp.mkdir(paths.outputDir, { recursive: true })
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
const events = []
const deps = makeDependencies({
root,
keys,
current: null,
events,
overrides: {
verifyHttp: async () => {
events.push('http-verify')
throw new Error('fixture HTTP mismatch')
}
}
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
resume: true,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'verify-http' && /HTTP mismatch/.test(error.message)
)
assert(events.some((event) => event.includes('deploy.mjs')))
assert.equal(events.at(-1), 'lock-release')
})
await test('resume permits the same server version but rejects a newer server latest', async () => {
const root = await makeRepository({ version: '1.0.1' })
temporaryDirectories.push(root)
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.1', keys, baseUrl })
const newer = signedManifest({ version: '1.0.2', keys, baseUrl })
const paths = releasePaths(root, '1.0.1')
await fsp.mkdir(paths.outputDir, { recursive: true })
await fsp.writeFile(paths.installerPath, 'fixture installer', 'utf8')
await fsp.writeFile(paths.manifestPath, local.text, 'utf8')
const events = []
const deps = makeDependencies({
root,
keys,
current: { ...newer, version: '1.0.2' },
events
})
await assert.rejects(
runReleaseWorkflow(
requestFor({
version: '1.0.1',
resume: true,
privateKeyPath: signingKeyProbePath,
publicKeyPath: path.join(os.tmpdir(), 'fake-public.pem')
}),
deps
),
(error) => error.stage === 'preflight' && /server latest is newer/.test(error.message)
)
assert(!events.some((event) => event.includes('deploy.mjs')))
assert.equal(events.at(-1), 'lock-release')
})
await test('release lock is mutually exclusive and released explicitly', async () => {
const directory = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-release-lock-selftest-'))
temporaryDirectories.push(directory)
const lockPath = path.join(directory, '.tmp', 'updates', 'release.lock')
const release = await acquireReleaseLock(lockPath, { version: '1.0.1' })
await assert.rejects(acquireReleaseLock(lockPath, { version: '1.0.2' }), /already exists/)
assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), true)
await release()
assert.equal(await fsp.readFile(lockPath, 'utf8').then(() => true, () => false), false)
const second = await acquireReleaseLock(lockPath, { version: '1.0.2' })
await second()
})
await test('HTTP verification checks no-cache request, exact bytes and signature', async () => {
const keys = generateEd25519KeyPair()
const baseUrl = normalizeBaseUrl('http://127.0.0.1/ycode/releases/')
const local = signedManifest({ version: '1.0.2', keys, baseUrl })
let requestOptions
const response = new Response(local.text, {
status: 200,
headers: { 'content-type': 'application/json', 'content-length': String(Buffer.byteLength(local.text)) }
})
const document = await verifyPublishedManifest({
baseUrl,
expectedManifestText: local.text,
publicKeyPem: keys.publicPem,
version: '1.0.2',
fetchImpl: async (_url, options) => {
requestOptions = options
return response
}
})
assert.equal(document.payload.version, '1.0.2')
assert.equal(requestOptions.cache, 'no-store')
assert.equal(requestOptions.credentials, 'omit')
assert.equal(requestOptions.redirect, 'error')
assert.equal(requestOptions.headers.pragma, 'no-cache')
await assert.rejects(
verifyPublishedManifest({
baseUrl,
expectedManifestText: local.text,
publicKeyPem: keys.publicPem,
version: '1.0.2',
fetchImpl: async () =>
new Response(local.text.replace('YCode 1.0.2', 'YCode 1.0.3'), {
status: 200,
headers: { 'content-length': String(Buffer.byteLength(local.text)) }
})
}),
/does not exactly match/
)
})
console.log(`\n${passed} release workflow self-tests passed. No SSH, network HTTP, build, installer, signing, deployment, repository version change or production release was performed.`)
} finally {
for (const directory of temporaryDirectories) {
await fsp.rm(directory, { recursive: true, force: true })
}
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+591
View File
@@ -0,0 +1,591 @@
import crypto from 'node:crypto'
import fsp from 'node:fs/promises'
import path from 'node:path'
import {
DEFAULT_BASE_URL,
DEFAULT_PRIVATE_KEY,
DEFAULT_PUBLIC_KEY,
DEFAULT_SSH_HOST,
assert,
assertOutsideRepo,
compareVersions,
createPrivateKey,
createPublicKey,
normalizeBaseUrl,
parseVersion,
verifySignedManifest
} from './lib.mjs'
export const STABLE_VERSION_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/
export const RELEASE_LOCK_RELATIVE = path.join('.tmp', 'updates', 'release.lock')
export const MAX_HTTP_MANIFEST_BYTES = 1024 * 1024
const CLI_VALUE_OPTIONS = new Set([
'version',
'notes-zh',
'notes-en',
'ssh-host',
'base-url',
'private-key'
])
export class ReleaseError extends Error {
constructor(stage, message, options = {}) {
super(message, options)
this.name = 'ReleaseError'
this.stage = stage
}
}
export function toReleaseError(stage, error) {
if (error instanceof ReleaseError) return error
return new ReleaseError(stage, String(error?.message || error), { cause: error })
}
async function runStage(stage, action) {
try {
return await action()
} catch (error) {
throw toReleaseError(stage, error)
}
}
export function validateStableVersion(value) {
const version = String(value ?? '')
assert(
STABLE_VERSION_RE.test(version),
'Release version must be a stable x.y.z version without a leading zero, prerelease or build metadata'
)
parseVersion(version)
for (const segment of version.split('.').map(BigInt)) {
assert(segment <= 65_535n, 'Release version segments must be between 0 and 65535 for Windows EXE metadata')
}
return version
}
export function defaultReleaseNotes(version) {
validateStableVersion(version)
return {
zh: `YCode ${version} 更新`,
en: `YCode ${version} update`
}
}
export async function resolveVersionChoice({ version, prompt }) {
if (version !== undefined) return validateStableVersion(version)
const answer = await prompt()
if (!answer || !answer.trim()) return null
return validateStableVersion(answer.trim())
}
export function parseReleaseArgs(argv, { cwd = process.cwd() } = {}) {
const raw = {}
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index]
if (!argument.startsWith('--')) throw new Error(`Unexpected argument: ${argument}`)
const equals = argument.indexOf('=')
const name = argument.slice(2, equals === -1 ? undefined : equals)
if (name === 'help' || name === 'resume') {
assert(equals === -1, `--${name} does not accept a value`)
assert(!(name in raw), `Option --${name} was provided more than once`)
raw[name] = true
continue
}
assert(CLI_VALUE_OPTIONS.has(name), `Unknown option: --${name}`)
assert(!(name in raw), `Option --${name} was provided more than once`)
const value = equals === -1 ? argv[++index] : argument.slice(equals + 1)
assert(value !== undefined && value !== '', `Missing value for --${name}`)
raw[name] = value
}
if (raw.help) {
return { help: true }
}
if (raw.resume) {
assert(raw.version, '--resume requires an explicit --version')
assert(!raw['notes-zh'] && !raw['notes-en'], '--notes-zh and --notes-en cannot be used with --resume')
}
const version = raw.version === undefined ? undefined : validateStableVersion(raw.version)
const notes = {}
if (raw['notes-zh'] !== undefined) notes.zh = raw['notes-zh'].trim()
if (raw['notes-en'] !== undefined) notes.en = raw['notes-en'].trim()
if ('zh' in notes) assert(notes.zh, '--notes-zh must not be empty')
if ('en' in notes) assert(notes.en, '--notes-en must not be empty')
return {
help: false,
resume: raw.resume === true,
version,
notes,
sshHost: raw['ssh-host'] || DEFAULT_SSH_HOST,
baseUrl: normalizeBaseUrl(raw['base-url'] || DEFAULT_BASE_URL),
privateKeyPath: path.resolve(cwd, raw['private-key'] || DEFAULT_PRIVATE_KEY),
publicKeyPath: path.resolve(cwd, DEFAULT_PUBLIC_KEY)
}
}
export function finalizeReleaseRequest(parsed, version) {
validateStableVersion(version)
const defaults = defaultReleaseNotes(version)
return {
...parsed,
version,
notes: {
zh: parsed.notes?.zh ?? defaults.zh,
en: parsed.notes?.en ?? defaults.en
}
}
}
export function releasePaths(root, version) {
validateStableVersion(version)
const outputDir = path.join(root, 'release', 'publish', version)
return {
root,
packagePath: path.join(root, 'package.json'),
lockPath: path.join(root, RELEASE_LOCK_RELATIVE),
outputDir,
installerPath: path.join(outputDir, `YCode-${version}-Setup-x64.exe`),
manifestPath: path.join(outputDir, 'latest.json'),
builderCli: path.join(root, 'node_modules', 'electron-builder', 'cli.js'),
prepareScript: path.join(root, 'scripts', 'updates', 'prepare-release.mjs'),
deployScript: path.join(root, 'scripts', 'updates', 'deploy.mjs')
}
}
function isOutside(root, filename) {
const relative = path.relative(path.resolve(root), path.resolve(filename))
return relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)
}
async function canonicalPrivateKeyPath({ root, filename, deps }) {
const lexical = path.resolve(filename)
assert(isOutside(root, lexical), `Private key must be outside the repository: ${lexical}`)
await assertOutsideRepo(lexical, 'Private key')
if (!(await deps.pathExists(lexical))) throw new Error(`Signing key not found: ${lexical}`)
const canonical = await deps.realpath(lexical)
assert(isOutside(root, canonical), `Private key resolves inside the repository: ${canonical}`)
await assertOutsideRepo(canonical, 'Private key')
return canonical
}
export function assertKeyPairMatches(privatePem, publicPem) {
const privateKey = createPrivateKey(privatePem)
const derived = createPublicKey(privateKey).export({ type: 'spki', format: 'der' })
const supplied = createPublicKey(publicPem).export({ type: 'spki', format: 'der' })
assert(derived.equals(supplied), 'Signing private key does not match the update public key')
}
function parseJson(text, label) {
try {
return JSON.parse(text)
} catch (error) {
throw new Error(`${label} is not valid JSON: ${error.message}`)
}
}
function jsonFormat(text) {
const newline = text.includes('\r\n') ? '\r\n' : '\n'
const indentMatch = text.match(/\n([ \t]+)"/)
return {
indent: indentMatch?.[1] || ' ',
newline,
trailingNewline: text.endsWith('\n')
}
}
function rewriteJson(text, mutate) {
const value = parseJson(text, 'JSON document')
mutate(value)
const format = jsonFormat(text)
let serialized = JSON.stringify(value, null, format.indent).replace(/\n/g, format.newline)
if (format.trailingNewline) serialized += format.newline
return serialized
}
export function updatePackageText(text, version) {
validateStableVersion(version)
return rewriteJson(text, (document) => {
assert(document.name === 'ycode', 'package.json is not the YCode package')
assert(document.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
document.version = version
})
}
export function updateLockText(text, version) {
validateStableVersion(version)
return rewriteJson(text, (document) => {
assert(document && typeof document === 'object', 'package-lock.json must be an object')
assert(
document.packages && typeof document.packages === 'object',
'package-lock.json has no root package entry'
)
assert(
document.packages[''] && typeof document.packages[''] === 'object',
'package-lock.json has no packages[""] entry'
)
document.version = version
document.packages[''].version = version
})
}
function packageVersions(packageText, lockText) {
const pkg = parseJson(packageText, 'package.json')
const lock = parseJson(lockText, 'package-lock.json')
assert(pkg.name === 'ycode', 'package.json is not the YCode package')
assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app')
const version = validateStableVersion(pkg.version)
assert(
lock.version !== undefined && lock.packages?.['']?.version !== undefined,
'package-lock.json must contain top-level and packages[""] versions'
)
assert(lock.version === version, 'package-lock.json top-level version does not match package.json')
assert(lock.packages[''].version === version, 'package-lock.json root version does not match package.json')
return { pkg, lock, version }
}
function sameManifest(left, right) {
return JSON.stringify(left) === JSON.stringify(right)
}
function parseSignedManifest(text, publicKeyPem, baseUrl, label) {
const document = parseJson(text, label)
verifySignedManifest(document, publicKeyPem, { baseUrl })
return document
}
export async function preflightRelease({ request, paths, deps }) {
const publicKeyPath = path.resolve(request.publicKeyPath)
const privateKeyPath = await canonicalPrivateKeyPath({
root: paths.root,
filename: request.privateKeyPath,
deps
})
const privatePem = await deps.readPrivateKey(privateKeyPath)
const publicPem = await deps.readPublicKey(publicKeyPath)
assertKeyPairMatches(privatePem, publicPem)
const packageLockPath = path.join(paths.root, 'package-lock.json')
for (const [label, filename] of [
['package.json', paths.packagePath],
['package-lock.json', packageLockPath]
]) {
assert(await deps.pathExists(filename), `${label} not found: ${filename}`)
}
const packageText = await deps.readFile(paths.packagePath, 'utf8')
const lockText = await deps.readFile(packageLockPath, 'utf8')
const versions = packageVersions(packageText, lockText)
if (request.resume) {
assert(
versions.version === request.version,
`--resume requires repository version ${request.version}; found ${versions.version}`
)
assert(await deps.pathExists(paths.outputDir), `Resume output directory not found: ${paths.outputDir}`)
assert(await deps.pathExists(paths.installerPath), `Resume installer not found: ${paths.installerPath}`)
assert(await deps.pathExists(paths.manifestPath), `Resume manifest not found: ${paths.manifestPath}`)
} else {
const compared = compareVersions(request.version, versions.version)
assert(
compared >= 0,
`Refusing to release ${request.version}; repository version is newer (${versions.version})`
)
assert(
!(await deps.pathExists(paths.outputDir)),
`Refusing to build an existing release directory: ${paths.outputDir}`
)
}
if (deps.checkToolchain) await deps.checkToolchain(paths, { resume: request.resume })
await deps.checkSsh(request.sshHost)
const current = await deps.readLatest({
host: request.sshHost,
publicKeyPem: publicPem,
baseUrl: request.baseUrl
})
let currentVersion = null
if (current?.document) {
verifySignedManifest(current.document, publicPem, { baseUrl: request.baseUrl })
currentVersion = current.document.payload.version
const compared = compareVersions(request.version, currentVersion)
if (request.resume) {
assert(
compared >= 0,
`Refusing resume for ${request.version}; server latest is newer (${currentVersion})`
)
} else {
assert(
compared > 0,
`Release version ${request.version} must be strictly newer than server latest ${currentVersion}`
)
}
}
let localManifestText = null
let localManifest = null
if (request.resume) {
localManifestText = await deps.readFile(paths.manifestPath, 'utf8')
localManifest = parseSignedManifest(localManifestText, publicPem, request.baseUrl, 'Local latest.json')
assert(
localManifest.payload.version === request.version,
`Resume manifest version ${localManifest.payload.version} does not match ${request.version}`
)
if (currentVersion === request.version) {
assert(
sameManifest(current.document, localManifest),
`Server already has a different ${request.version} manifest`
)
}
const bundle = await deps.validateBundle({
manifestPath: paths.manifestPath,
installerPath: paths.installerPath,
publicKeyPath,
baseUrl: request.baseUrl
})
assert(
bundle?.document?.payload?.version === request.version,
`Revalidated bundle version does not match ${request.version}`
)
}
return {
repoVersion: versions.version,
privateKeyPath,
packageText,
lockText,
publicPem,
currentVersion,
currentRaw: current?.raw ?? null,
localManifestText,
localManifest
}
}
async function updateTargetVersions({ request, paths, preflight, deps }) {
if (request.resume) return { changed: false }
const nextPackageText = updatePackageText(preflight.packageText, request.version)
const nextLockText = updateLockText(preflight.lockText, request.version)
let changed = false
if (nextPackageText !== preflight.packageText) {
await deps.writeFileAtomic(paths.packagePath, nextPackageText)
changed = true
}
if (nextLockText !== preflight.lockText) {
await deps.writeFileAtomic(path.join(paths.root, 'package-lock.json'), nextLockText)
changed = true
}
return { changed }
}
export async function acquireReleaseLock(lockPath, { version } = {}) {
await fsp.mkdir(path.dirname(lockPath), { recursive: true })
const token = crypto.randomBytes(16).toString('hex')
let handle
let created = false
try {
handle = await fsp.open(lockPath, 'wx', 0o600)
created = true
await handle.writeFile(
`${JSON.stringify({
token,
pid: process.pid,
version: version || null,
startedAt: new Date().toISOString()
})}\n`,
'utf8'
)
await handle.sync()
} catch (error) {
if (handle) await handle.close().catch(() => {})
if (created) await fsp.rm(lockPath, { force: true }).catch(() => {})
if (error?.code === 'EEXIST') {
throw new Error(`Release lock already exists: ${lockPath}`)
}
throw error
}
await handle.close()
let released = false
return async function releaseLock() {
if (released) return
let raw
try {
raw = await fsp.readFile(lockPath, 'utf8')
} catch (error) {
if (error?.code === 'ENOENT') {
released = true
return
}
throw error
}
let record
try {
record = JSON.parse(raw)
} catch {
throw new Error(`Release lock was replaced or corrupted: ${lockPath}`)
}
assert(record.token === token, `Release lock ownership changed: ${lockPath}`)
await fsp.rm(lockPath, { force: true })
released = true
}
}
async function readResponseLimited(response, maxBytes) {
const declared = Number(response.headers.get('content-length'))
if (Number.isFinite(declared) && declared > maxBytes) {
throw new Error(`latest.json exceeds ${maxBytes} bytes`)
}
if (!response.body) return Buffer.alloc(0)
const reader = response.body.getReader()
const chunks = []
let total = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
total += value.byteLength
if (total > maxBytes) {
await reader.cancel()
throw new Error(`latest.json exceeds ${maxBytes} bytes`)
}
chunks.push(Buffer.from(value))
}
return Buffer.concat(chunks, total)
}
export async function verifyPublishedManifest({
baseUrl,
expectedManifestText,
publicKeyPem,
version,
fetchImpl = globalThis.fetch,
timeoutMs = 30_000,
maxBytes = MAX_HTTP_MANIFEST_BYTES
}) {
validateStableVersion(version)
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
assert(typeof fetchImpl === 'function', 'fetch is not available')
const manifestUrl = new URL('latest.json', normalizedBaseUrl)
const response = await fetchImpl(manifestUrl, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
headers: {
accept: 'application/json',
'cache-control': 'no-cache',
pragma: 'no-cache'
},
signal: AbortSignal.timeout(timeoutMs)
})
assert(response.status === 200, `latest.json HTTP status is ${response.status}, expected 200`)
const expected = Buffer.from(expectedManifestText, 'utf8')
assert(expected.length <= maxBytes, 'Local signed manifest exceeds the HTTP verification limit')
const actual = await readResponseLimited(response, maxBytes)
assert(actual.equals(expected), 'HTTP latest.json does not exactly match the local signed manifest')
const document = parseSignedManifest(actual.toString('utf8'), publicKeyPem, normalizedBaseUrl, 'HTTP latest.json')
assert(document.payload.version === version, `HTTP latest.json version is ${document.payload.version}, expected ${version}`)
return document
}
function deployArgs({ request, paths }) {
return [
'--manifest',
paths.manifestPath,
'--installer',
paths.installerPath,
'--transport',
'ssh',
'--base-url',
request.baseUrl,
'--public-key',
request.publicKeyPath,
'--ssh-host',
request.sshHost
]
}
export async function runReleaseWorkflow(request, deps) {
validateStableVersion(request.version)
const paths = releasePaths(deps.root, request.version)
let releaseLock
let primaryError = null
releaseLock = await runStage('lock', () => deps.acquireReleaseLock(paths.lockPath, { version: request.version }))
assert(typeof releaseLock === 'function', 'Release lock must return a release function')
try {
const preflight = await runStage('preflight', () => preflightRelease({ request, paths, deps }))
if (!request.resume) {
await runStage('version-update', () => updateTargetVersions({ request, paths, preflight, deps }))
await runStage('test-updates', () => deps.runNpm(['run', 'test:updates']))
await runStage('build', () => deps.runNpm(['run', 'build']))
await runStage('package', () =>
deps.runNode([
paths.builderCli,
'--win',
'nsis',
'--x64',
'--publish',
'never',
`--config.directories.output=${path.relative(paths.root, paths.outputDir).replaceAll(path.sep, '/')}`
])
)
await runStage('sign', () =>
deps.runNode([
paths.prepareScript,
'--installer',
paths.installerPath,
'--version',
request.version,
'--notes-zh',
request.notes.zh,
'--notes-en',
request.notes.en,
'--base-url',
request.baseUrl,
'--private-key',
preflight.privateKeyPath,
'--public-key',
request.publicKeyPath,
'--output',
paths.manifestPath
])
)
}
await runStage('deploy-dry-run', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths }), '--dry-run']))
await runStage('deploy', () => deps.runNode([paths.deployScript, ...deployArgs({ request, paths })]))
await runStage('verify-http', async () => {
const expectedManifestText =
preflight.localManifestText || (await deps.readFile(paths.manifestPath, 'utf8'))
await deps.verifyHttp({
baseUrl: request.baseUrl,
expectedManifestText,
publicKeyPem: preflight.publicPem,
version: request.version
})
})
return {
version: request.version,
outputDir: paths.outputDir,
installerPath: paths.installerPath,
manifestPath: paths.manifestPath
}
} catch (error) {
primaryError = toReleaseError('workflow', error)
throw primaryError
} finally {
try {
await releaseLock()
} catch (error) {
if (!primaryError) throw toReleaseError('lock-release', error)
if (deps.logWarning) deps.logWarning(`Release lock cleanup failed: ${error.message}`)
}
}
}
+169
View File
@@ -0,0 +1,169 @@
import { spawn } from 'node:child_process'
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { createInterface } from 'node:readline/promises'
import {
DEFAULT_BASE_URL,
REPO_ROOT,
readPrivateKey,
readPublicKey,
runSsh,
writeFileAtomic
} from './lib.mjs'
import {
ReleaseError,
acquireReleaseLock,
finalizeReleaseRequest,
parseReleaseArgs,
resolveVersionChoice,
runReleaseWorkflow,
toReleaseError,
verifyPublishedManifest
} from './release-workflow.mjs'
import { readLatestViaSsh, validateReleaseBundle } from './transport.mjs'
const usage = `Usage:
npm run release
npm run release -- --version <x.y.z> [--notes-zh <text>] [--notes-en <text>]
npm run release -- --resume --version <x.y.z>
npm run release -- --help
Options:
--version <x.y.z> Stable Windows x64 version. Prompted when omitted.
--notes-zh <text> Chinese release notes. Defaults to "YCode <version> 更新".
--notes-en <text> English release notes. Defaults to "YCode <version> update".
--resume Reuse an existing installer and signed manifest.
--ssh-host <host> SSH host, default local001.
--base-url <url> Release base URL, default ${DEFAULT_BASE_URL}.
--private-key <path> Signing key, default in the current user profile.
--help Show this help.
The workflow runs tests, builds, signs, deploys, and verifies latest.json.
It does not create keys, roll back the server, or clean stale release locks.
`
async function pathExists(filename) {
try {
await fsp.access(filename)
return true
} catch (error) {
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') return false
throw error
}
}
function runExecutable(command, args, { cwd = REPO_ROOT } = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
cwd,
env: process.env,
shell: false,
stdio: 'inherit',
windowsHide: true
})
child.once('error', reject)
child.once('exit', (code, signal) => {
if (code === 0) {
resolve()
} else {
reject(new Error(`${path.basename(command)} exited with ${signal ? `signal ${signal}` : `code ${code}`}`))
}
})
})
}
function resolveNpmCli() {
const candidates = [
path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'),
process.env.npm_execpath
].filter((candidate) => candidate && path.basename(candidate).toLowerCase() === 'npm-cli.js')
const npmCli = candidates.find((candidate) => fs.existsSync(candidate))
if (!npmCli) throw new Error('Unable to locate npm-cli.js; run this command through npm run release')
return npmCli
}
async function promptForVersion() {
const readline = createInterface({ input: process.stdin, output: process.stdout })
try {
return await readline.question('Release version (stable x.y.z): ')
} finally {
readline.close()
}
}
function productionDependencies() {
const npmCli = resolveNpmCli()
return {
root: REPO_ROOT,
pathExists,
realpath: fsp.realpath,
readFile: fsp.readFile,
writeFileAtomic,
readPrivateKey,
readPublicKey,
validateBundle: validateReleaseBundle,
acquireReleaseLock,
checkToolchain: async (paths, { resume }) => {
const required = [['deploy.mjs', paths.deployScript]]
if (!resume) {
required.unshift(
['npm-cli.js', npmCli],
['electron-builder CLI', paths.builderCli],
['prepare-release.mjs', paths.prepareScript]
)
}
for (const [label, filename] of required) {
if (!fs.existsSync(filename)) throw new Error(`${label} not found: ${filename}`)
}
},
checkSsh: async (host) => {
runSsh(host, 'true\n')
},
readLatest: ({ host, publicKeyPem, baseUrl }) => readLatestViaSsh(host, publicKeyPem, baseUrl),
runNpm: (args) => runExecutable(process.execPath, [npmCli, ...args]),
runNode: (args) => runExecutable(process.execPath, args),
verifyHttp: verifyPublishedManifest,
logWarning: (message) => console.warn(message)
}
}
async function main() {
let parsed
try {
parsed = parseReleaseArgs(process.argv.slice(2))
} catch (error) {
throw toReleaseError('input', error)
}
if (parsed.help) {
process.stdout.write(usage)
return
}
let version
try {
version = await resolveVersionChoice({ version: parsed.version, prompt: promptForVersion })
} catch (error) {
throw toReleaseError('prompt', error)
}
if (!version) {
console.log('Release cancelled.')
return
}
let request
try {
request = finalizeReleaseRequest(parsed, version)
} catch (error) {
throw toReleaseError('input', error)
}
const result = await runReleaseWorkflow(request, productionDependencies())
console.log(`Release ${result.version} published and verified.`)
console.log(`Installer: ${result.installerPath}`)
console.log(`Manifest: ${result.manifestPath}`)
}
main().catch((error) => {
const releaseError = error instanceof ReleaseError ? error : toReleaseError('input', error)
console.error(`Release failed during ${releaseError.stage}: ${releaseError.message}`)
process.exitCode = 1
})
+372
View File
@@ -0,0 +1,372 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import fsp from 'node:fs/promises'
import http from 'node:http'
import os from 'node:os'
import path from 'node:path'
import {
REPO_ROOT,
buildManifestPayload,
compareVersions,
createSignedManifest,
normalizeBaseUrl,
readPrivateKey,
readPublicKey,
renderDownloadPage,
runProcess,
serializeSignedManifest,
signPayload,
verifyPayloadSignature,
verifySignedManifest
} from './lib.mjs'
import {
publishLocalRelease,
readLocalLatest
} from './transport.mjs'
let passed = 0
async function test(name, callback) {
await callback()
passed += 1
console.log(`PASS ${name}`)
}
function expectedError(pattern) {
return (error) => {
assert.match(String(error?.message || error), pattern)
return true
}
}
function sha256(buffer) {
return createHash('sha256').update(buffer).digest('hex')
}
function contentType(filename) {
return filename.endsWith('.json') ? 'application/json' : 'application/octet-stream'
}
function startStaticServer(root) {
const server = http.createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://127.0.0.1')
const pathname = decodeURIComponent(url.pathname)
const relative = pathname.startsWith('/ycode/releases/')
? pathname.slice('/ycode/releases/'.length)
: null
assert(relative !== null, 'unexpected path')
const requested = relative === '' || relative.endsWith('/') ? `${relative}index.html` : relative
const filename = path.resolve(root, 'ycode', 'releases', requested)
const releaseRoot = path.resolve(root, 'ycode', 'releases')
assert(filename.startsWith(`${releaseRoot}${path.sep}`), 'path traversal')
const data = await fsp.readFile(filename)
response.writeHead(200, {
'content-length': data.length,
'content-type': contentType(filename)
})
response.end(data)
} catch {
response.writeHead(404)
response.end('not found')
}
})
return new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
const address = server.address()
resolve({ server, origin: `http://127.0.0.1:${address.port}` })
})
})
}
async function releaseFor({ version, bytes, filename, baseUrl, privatePem, publicPem, artifactPath }) {
const installer = {
filename,
size: bytes.length,
sha256: sha256(bytes)
}
const payload = buildManifestPayload({
version,
publishedAt: '2026-10-20T10:00:00.000Z',
notes: { zh: `版本 ${version} 测试说明`, en: `Release ${version} test notes` },
installer,
baseUrl
})
const document = createSignedManifest({
payload,
privateKeyPem: privatePem,
publicKeyPem: publicPem,
baseUrl
})
return {
document,
installer: { ...installer, path: artifactPath },
manifestText: serializeSignedManifest(document, { baseUrl }),
publicKeyPem: publicPem,
baseUrl
}
}
async function main() {
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-selftest-'))
const privateKey = path.join(temporary, 'keys', 'update-signing.key')
const publicKey = path.join(temporary, 'keys', 'public-key.pem')
const publicRoot = path.join(temporary, 'ycode')
const artifactDirectory = path.join(temporary, 'fixtures')
await fsp.mkdir(artifactDirectory, { recursive: true })
let server
try {
await test('key generation stays outside the repository and writes a PEM trust anchor', async () => {
const initKey = path.join(REPO_ROOT, 'scripts', 'updates', 'init-key.mjs')
const output = runProcess(process.execPath, [initKey, '--private-key', privateKey, '--public-key', publicKey])
assert.match(output, /Private key:/)
assert.doesNotMatch(output, /BEGIN PRIVATE KEY/)
const privatePem = await fsp.readFile(privateKey, 'utf8')
const publicPem = await fsp.readFile(publicKey, 'utf8')
assert.match(privatePem, /BEGIN PRIVATE KEY/)
assert.match(publicPem, /BEGIN PUBLIC KEY/)
if (process.platform === 'win32') {
const acl = runProcess('icacls', [privateKey])
assert.doesNotMatch(acl, /\(I\)/)
}
})
const privatePem = await readPrivateKey(privateKey)
const publicPem = await readPublicKey(publicKey)
await test('SemVer precedence is strict and build metadata does not create a newer release', async () => {
assert(compareVersions('2.0.0', '1.9.9') > 0)
assert(compareVersions('2.0.0-rc.2', '2.0.0-rc.10') < 0)
assert(compareVersions('2.0.0+1', '2.0.0+2') === 0)
assert.throws(() => compareVersions('01.0.0', '1.0.0'), /strict SemVer/)
assert.throws(() => compareVersions('1.0', '1.0.0'), /strict SemVer/)
})
const staticServer = await startStaticServer(temporary)
server = staticServer.server
const baseUrl = normalizeBaseUrl(`${staticServer.origin}/ycode/releases/`)
const version1 = '2.0.0'
const bytes1 = Buffer.from(`fake installer ${version1} `.repeat(16_384), 'utf8')
const filename1 = `YCode-${version1}-Setup-x64.exe`
const artifact1 = path.join(artifactDirectory, filename1)
await fsp.writeFile(artifact1, bytes1)
const release1 = await releaseFor({
version: version1,
bytes: bytes1,
filename: filename1,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact1
})
await test('manifest signs exact JSON.stringify(payload) and rejects any payload or signature tamper', async () => {
verifySignedManifest(release1.document, publicPem, { baseUrl })
assert(verifyPayloadSignature(release1.document.payload, release1.document.signature, publicPem))
const notesTamper = structuredClone(release1.document)
notesTamper.payload.notes.en = 'tampered'
assert.throws(() => verifySignedManifest(notesTamper, publicPem, { baseUrl }), /signature is invalid/)
const hashTamper = structuredClone(release1.document)
hashTamper.payload.platforms['windows-x64'].sha256 = '0'.repeat(64)
assert.throws(() => verifySignedManifest(hashTamper, publicPem, { baseUrl }), /signature is invalid/)
const signatureTamper = structuredClone(release1.document)
const first = signatureTamper.signature[0] === 'A' ? 'B' : 'A'
signatureTamper.signature = first + signatureTamper.signature.slice(1)
assert.throws(() => verifySignedManifest(signatureTamper, publicPem, { baseUrl }), /signature is invalid/)
})
await test('signed manifests reject query, fragment, credentials, cross-version paths and non-canonical timestamps', async () => {
const cases = [
(payload) => { payload.platforms['windows-x64'].url += '?token=bad' },
(payload) => { payload.platforms['windows-x64'].url += '#fragment' },
(payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('127.0.0.1', 'user:pass@127.0.0.1') },
(payload) => { payload.platforms['windows-x64'].url = payload.platforms['windows-x64'].url.replace('/versions/2.0.0/', '/versions/2.0.1/') },
(payload) => { payload.publishedAt = '2026-10-20T10:00:00Z' },
(payload) => { payload.platforms['windows-x64'].sha256 = payload.platforms['windows-x64'].sha256.toUpperCase() }
]
for (const mutate of cases) {
const payload = structuredClone(release1.document.payload)
mutate(payload)
const document = { payload, signature: signPayload(payload, privatePem) }
assert.throws(() => verifySignedManifest(document, publicPem, { baseUrl }), /Manifest|URL|publishedAt|sha256/)
}
})
await test('download page escapes signed notes and contains no script', async () => {
const payload = structuredClone(release1.document.payload)
payload.notes.zh = '<script>alert("zh")</script>\n第二行'
payload.notes.en = '<img src=x onerror=alert(1)>'
const html = renderDownloadPage(payload, { baseUrl })
assert.doesNotMatch(html, /<script>alert/)
assert.doesNotMatch(html, /<img src=x/)
assert.match(html, /&lt;script&gt;/)
assert.match(html, /&lt;img src=x onerror=alert\(1\)&gt;/)
assert.match(html, /Content-Security-Policy/)
})
await test('local fake transport publishes immutable artifact then fetches and verifies it over HTTP', async () => {
let current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release: release1, current })
const response = await fetch(new URL('latest.json', baseUrl))
assert.equal(response.status, 200)
assert.equal(response.headers.get('cache-control'), null)
const text = await response.text()
const document = JSON.parse(text)
verifySignedManifest(document, publicPem, { baseUrl })
const artifactResponse = await fetch(document.payload.platforms['windows-x64'].url)
const downloaded = Buffer.from(await artifactResponse.arrayBuffer())
assert.equal(sha256(downloaded), document.payload.platforms['windows-x64'].sha256)
assert.equal(downloaded.length, document.payload.platforms['windows-x64'].size)
const pageResponse = await fetch(new URL('./', baseUrl))
assert.equal(pageResponse.status, 200)
const page = await pageResponse.text()
assert.match(page, /v2\.0\.0/)
assert.match(page, new RegExp(document.payload.platforms['windows-x64'].sha256))
assert.doesNotMatch(page, /<script\b/i)
})
await test('exact redeploy is idempotent', async () => {
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release: release1, current })
})
await test('a conflicting artifact in an existing version directory is rejected', async () => {
const bytes = Buffer.from('conflicting same-version bytes', 'utf8')
const artifact = path.join(artifactDirectory, filename1)
await fsp.writeFile(artifact, bytes)
const conflicting = await releaseFor({
version: version1,
bytes,
filename: filename1,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(current.document.payload.version, version1)
await fsp.writeFile(artifact, bytes1)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
expectedError(/different manifest/)
)
})
await test('a lower version is rejected without changing latest.json', async () => {
const bytes = Buffer.from('lower release fixture', 'utf8')
const filename = 'YCode-1.9.9-Setup-x64.exe'
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const lower = await releaseFor({
version: '1.9.9',
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: lower, current }),
expectedError(/newer/)
)
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(after.raw, current.raw)
})
await test('failure before latest.json swap leaves the previous signed manifest live', async () => {
const version = '2.1.0'
const bytes = Buffer.from('failed atomic fixture', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const failed = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const before = await readLocalLatest(publicRoot, publicPem, baseUrl)
const pageBefore = await (await fetch(new URL('./', baseUrl))).text()
await assert.rejects(
publishLocalRelease({
root: publicRoot,
release: failed,
current: before,
beforeLatestSwap: async () => { throw new Error('injected publication failure') }
}),
expectedError(/injected publication failure/)
)
const after = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(after.raw, before.raw)
const pageAfter = await (await fetch(new URL('./', baseUrl))).text()
assert.equal(pageAfter, pageBefore)
})
await test('a different artifact cannot reuse a version directory left by a failed publication', async () => {
const version = '2.1.0'
const bytes = Buffer.from('different failed artifact', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const conflicting = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await assert.rejects(
publishLocalRelease({ root: publicRoot, release: conflicting, current }),
expectedError(/conflicting artifact/)
)
})
await test('successful publication swaps the generated download page with latest.json', async () => {
const version = '2.2.0'
const bytes = Buffer.from('successful next release fixture', 'utf8')
const filename = `YCode-${version}-Setup-x64.exe`
const artifact = path.join(artifactDirectory, filename)
await fsp.writeFile(artifact, bytes)
const release = await releaseFor({
version,
bytes,
filename,
baseUrl,
privatePem,
publicPem,
artifactPath: artifact
})
const current = await readLocalLatest(publicRoot, publicPem, baseUrl)
await publishLocalRelease({ root: publicRoot, release, current })
const latest = await readLocalLatest(publicRoot, publicPem, baseUrl)
assert.equal(latest.document.payload.version, version)
const page = await (await fetch(new URL('./', baseUrl))).text()
assert.match(page, /v2\.2\.0/)
assert.match(page, new RegExp(latest.document.payload.platforms['windows-x64'].sha256))
})
console.log(`\n${passed} release-tool self-tests passed. No SSH target or production latest.json was touched.`)
} finally {
if (server) await new Promise((resolve) => server.close(resolve))
await fsp.rm(temporary, { recursive: true, force: true })
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})
+408
View File
@@ -0,0 +1,408 @@
import crypto from 'node:crypto'
import fsp from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import {
DEFAULT_BASE_URL,
SERVER_RELEASES_ROOT,
SERVER_STAGING_ROOT,
assert,
compareVersions,
normalizeBaseUrl,
parseVersion,
readJson,
readPublicKey,
renderDownloadPage,
serializeSignedManifest,
sha256File,
verifyReleaseInstaller,
verifySignedManifest,
runProcess,
runSsh,
validateSshHost,
writeFileAtomic
} from './lib.mjs'
const MAX_MANIFEST_BYTES = 1024 * 1024
export function parseCurrentManifest(raw, publicKeyPem, baseUrl) {
assert(Buffer.byteLength(raw, 'utf8') <= MAX_MANIFEST_BYTES, 'Current latest.json exceeds 1 MiB')
let document
try {
document = JSON.parse(raw)
} catch {
throw new Error('Current latest.json is not valid JSON')
}
verifySignedManifest(document, publicKeyPem, { baseUrl })
return document
}
export function assertMonotonicTransition(current, target) {
if (!current) return
const compared = compareVersions(target.payload.version, current.payload.version)
assert(compared >= 0, `Refusing to publish ${target.payload.version}; server latest is newer (${current.payload.version})`)
if (compared === 0) {
assert(
JSON.stringify(current) === JSON.stringify(target),
`Refusing to replace ${target.payload.version} with a different manifest`
)
}
}
export async function validateReleaseBundle({
manifestPath,
installerPath,
publicKeyPath,
baseUrl = DEFAULT_BASE_URL
}) {
const normalizedBaseUrl = normalizeBaseUrl(baseUrl)
const publicKeyPem = await readPublicKey(publicKeyPath)
let document
try {
document = await readJson(manifestPath)
} catch (error) {
throw new Error(`Unable to read release manifest: ${error.message}`)
}
verifySignedManifest(document, publicKeyPem, { baseUrl: normalizedBaseUrl })
const expected = document.payload.platforms['windows-x64']
const installer = await verifyReleaseInstaller(installerPath, document.payload.version)
assert(installer.size === expected.size, 'Installer size does not match the signed manifest')
assert(installer.sha256 === expected.sha256, 'Installer SHA-256 does not match the signed manifest')
assert(path.posix.basename(new URL(expected.url).pathname) === installer.filename, 'Signed URL filename does not match the installer')
return {
document,
manifestText: serializeSignedManifest(document, { baseUrl: normalizedBaseUrl }),
pageHtml: renderDownloadPage(document.payload, { baseUrl: normalizedBaseUrl }),
installer,
publicKeyPem,
baseUrl: normalizedBaseUrl
}
}
export async function readLocalLatest(root, publicKeyPem, baseUrl) {
const filename = path.join(root, 'releases', 'latest.json')
try {
const raw = await fsp.readFile(filename, 'utf8')
return { raw, document: parseCurrentManifest(raw, publicKeyPem, baseUrl) }
} catch (error) {
if (error && error.code === 'ENOENT') return { raw: null, document: null }
throw error
}
}
async function installVersionDirectory({ root, version, installerPath, expectedSha256, runId }) {
const releases = path.join(root, 'releases')
const versions = path.join(releases, 'versions')
const target = path.join(versions, version)
const filename = path.basename(installerPath)
await fsp.mkdir(versions, { recursive: true })
try {
const entries = await fsp.readdir(target)
assert(entries.length === 1 && entries[0] === filename, `Version directory ${version} already conflicts with this artifact`)
const existingHash = await sha256File(path.join(target, filename))
assert(existingHash === expectedSha256, `Version directory ${version} contains a conflicting artifact`)
return
} catch (error) {
if (!error || error.code !== 'ENOENT') throw error
}
const staging = path.join(root, '.staging', runId)
const stagedVersion = path.join(staging, 'version')
const stagedFile = path.join(stagedVersion, filename)
await fsp.mkdir(stagedVersion, { recursive: true })
try {
await fsp.copyFile(installerPath, stagedFile)
const copiedHash = await sha256File(stagedFile)
assert(copiedHash === expectedSha256, 'Staged installer hash changed')
await fsp.rename(stagedVersion, target)
} finally {
await fsp.rm(staging, { recursive: true, force: true })
}
}
export async function publishLocalRelease({
root,
release,
current,
beforeLatestSwap
}) {
const { document, installer, manifestText, publicKeyPem, baseUrl } = release
assertMonotonicTransition(current?.document, document)
await installVersionDirectory({
root,
version: document.payload.version,
installerPath: installer.path,
expectedSha256: document.payload.platforms['windows-x64'].sha256,
runId: crypto.randomBytes(12).toString('hex')
})
const latestPath = path.join(root, 'releases', 'latest.json')
const indexPath = path.join(root, 'releases', 'index.html')
let previousPage = null
try {
previousPage = await fsp.readFile(indexPath)
} catch (error) {
if (!error || error.code !== 'ENOENT') throw error
}
const assertCurrentLatest = async () => {
if (current?.raw != null) {
const liveRaw = await fsp.readFile(latestPath, 'utf8')
assert(liveRaw === current.raw, 'latest.json changed during publication')
return
}
try {
await fsp.access(latestPath)
throw new Error('latest.json appeared during publication')
} catch (error) {
if (error && error.code !== 'ENOENT') throw error
}
}
await assertCurrentLatest()
try {
await writeFileAtomic(
indexPath,
release.pageHtml || renderDownloadPage(document.payload, { baseUrl }),
0o644
)
if (beforeLatestSwap) await beforeLatestSwap()
await assertCurrentLatest()
await writeFileAtomic(latestPath, manifestText, 0o644)
} catch (error) {
try {
if (previousPage == null) {
await fsp.rm(indexPath, { force: true })
} else {
await writeFileAtomic(indexPath, previousPage, 0o644)
}
} catch (rollbackError) {
throw new Error(`${error.message}; failed to restore download page: ${rollbackError.message}`)
}
throw error
}
return { latestPath, document }
}
export async function readLatestViaSsh(host, publicKeyPem, baseUrl) {
const script = `set -euo pipefail
if [ -f '${SERVER_RELEASES_ROOT}/latest.json' ]; then
cat '${SERVER_RELEASES_ROOT}/latest.json'
fi
`
const raw = runSsh(host, script)
if (!raw) return { raw: null, document: null }
assert(Buffer.byteLength(raw, 'utf8') <= MAX_MANIFEST_BYTES, 'Server latest.json exceeds 1 MiB')
return { raw, document: parseCurrentManifest(raw, publicKeyPem, baseUrl) }
}
function sha256Text(value) {
return crypto.createHash('sha256').update(value, 'utf8').digest('hex')
}
export async function publishViaSsh({
host,
release,
current
}) {
validateSshHost(host)
const { document, installer, manifestText, publicKeyPem, baseUrl } = release
assertMonotonicTransition(current?.document, document)
const runId = crypto.randomBytes(12).toString('hex')
const version = document.payload.version
const filename = installer.filename
parseVersion(version)
assert(/^[A-Za-z0-9._-]+\.exe$/.test(filename), 'Unsafe installer filename')
const staging = `${SERVER_STAGING_ROOT}/${runId}`
const manifestSha256 = sha256Text(manifestText)
const pageHtml = renderDownloadPage(document.payload, { baseUrl })
const pageSha256 = sha256Text(pageHtml)
const metadata = {
runId,
version,
filename,
sha256: document.payload.platforms['windows-x64'].sha256,
size: document.payload.platforms['windows-x64'].size,
manifestSha256,
pageSha256,
expectedLatestSha256: current?.raw == null ? null : sha256Text(current.raw)
}
runSsh(host, `set -euo pipefail
install -d -o root -g root -m 0700 '${SERVER_STAGING_ROOT}'
install -d -o root -g root -m 0700 '${staging}'
`)
let publicationError
try {
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', installer.path, `${host}:${staging}/${filename}`])
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'ycode-update-deploy-'))
const localManifest = path.join(temporary, 'latest.json')
const localPage = path.join(temporary, 'index.html')
await fsp.writeFile(localManifest, manifestText, { mode: 0o600 })
await fsp.writeFile(localPage, pageHtml, { mode: 0o600 })
try {
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', localManifest, `${host}:${staging}/latest.json`])
runProcess('scp', ['-q', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', localPage, `${host}:${staging}/index.html`])
} finally {
await fsp.rm(temporary, { recursive: true, force: true })
}
const metadataBase64 = Buffer.from(JSON.stringify(metadata), 'utf8').toString('base64')
const remoteScript = `set -euo pipefail
umask 022
metadata_file="$(mktemp /tmp/ycode-release.XXXXXX.json)"
cleanup() {
rm -f "$metadata_file"
rm -rf '${staging}'
}
trap cleanup EXIT
printf '%s' '${metadataBase64}' | base64 -d > "$metadata_file"
python3 - "$metadata_file" <<'PY'
import fcntl
import hashlib
import json
import os
import re
import shutil
import sys
meta = json.load(open(sys.argv[1], "r", encoding="utf-8"))
run_id = meta["runId"]
version = meta["version"]
filename = meta["filename"]
expected_hash = meta["sha256"]
expected_size = meta["size"]
manifest_hash = meta["manifestSha256"]
page_hash = meta["pageSha256"]
expected_latest_hash = meta["expectedLatestSha256"]
if not re.fullmatch(r"[0-9A-Fa-f]{24}", run_id):
raise SystemExit("invalid staging id")
if not re.fullmatch(r"[0-9A-Za-z._-]+\\.exe", filename):
raise SystemExit("invalid installer filename")
if not re.fullmatch(r"[0-9]+\\.[0-9]+\\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?", version):
raise SystemExit("invalid version")
public_root = "${path.posix.dirname(SERVER_RELEASES_ROOT)}"
releases = "${SERVER_RELEASES_ROOT}"
staging = "${SERVER_STAGING_ROOT}/" + run_id
versions = os.path.join(releases, "versions")
target_dir = os.path.join(versions, version)
staged_artifact = os.path.join(staging, filename)
staged_manifest = os.path.join(staging, "latest.json")
staged_page = os.path.join(staging, "index.html")
latest = os.path.join(releases, "latest.json")
index = os.path.join(releases, "index.html")
def digest(pathname):
value = hashlib.sha256()
with open(pathname, "rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
value.update(chunk)
return value.hexdigest()
if os.stat(staging).st_dev != os.stat(public_root).st_dev:
raise SystemExit("staging and release roots are on different filesystems")
os.makedirs(versions, mode=0o755, exist_ok=True)
os.makedirs("${SERVER_STAGING_ROOT}", mode=0o700, exist_ok=True)
lock_path = "/var/lock/ycode-release.lock"
with open(lock_path, "a+", encoding="utf-8") as lock:
fcntl.flock(lock.fileno(), fcntl.LOCK_EX)
live_latest_hash = digest(latest) if os.path.isfile(latest) else None
if live_latest_hash != expected_latest_hash:
raise SystemExit("latest.json changed during publication")
if os.path.getsize(staged_artifact) != expected_size or digest(staged_artifact) != expected_hash:
raise SystemExit("staged installer does not match signed metadata")
if os.path.getsize(staged_manifest) > 1024 * 1024 or digest(staged_manifest) != manifest_hash:
raise SystemExit("staged manifest does not match expected bytes")
if os.path.getsize(staged_page) > 512 * 1024 or digest(staged_page) != page_hash:
raise SystemExit("staged download page does not match expected bytes")
if os.path.exists(target_dir):
entries = sorted(os.listdir(target_dir))
if entries != [filename]:
raise SystemExit("version directory conflicts with this artifact")
if digest(os.path.join(target_dir, filename)) != expected_hash:
raise SystemExit("version directory contains a conflicting artifact")
else:
version_stage = os.path.join(staging, "version")
os.makedirs(version_stage, mode=0o755, exist_ok=False)
os.replace(staged_artifact, os.path.join(version_stage, filename))
os.chmod(os.path.join(version_stage, filename), 0o644)
os.replace(version_stage, target_dir)
previous_page = None
if os.path.isfile(index):
with open(index, "rb") as stream:
previous_page = stream.read()
if len(previous_page) > 512 * 1024:
raise SystemExit("existing download page is unexpectedly large")
next_latest = os.path.join(releases, f".latest.json.{run_id}.tmp")
next_page = os.path.join(releases, f".index.html.{run_id}.tmp")
os.replace(staged_manifest, next_latest)
os.chmod(next_latest, 0o644)
with open(next_latest, "rb") as stream:
os.fsync(stream.fileno())
os.replace(staged_page, next_page)
os.chmod(next_page, 0o644)
with open(next_page, "rb") as stream:
os.fsync(stream.fileno())
page_swapped = False
try:
os.replace(next_page, index)
page_swapped = True
os.replace(next_latest, latest)
except Exception:
if page_swapped:
if previous_page is None:
try:
os.unlink(index)
except FileNotFoundError:
pass
else:
rollback_page = os.path.join(releases, f".index.html.{run_id}.rollback")
with open(rollback_page, "wb") as stream:
stream.write(previous_page)
stream.flush()
os.fsync(stream.fileno())
os.chmod(rollback_page, 0o644)
os.replace(rollback_page, index)
raise
directory_fd = os.open(releases, os.O_RDONLY)
try:
os.fsync(directory_fd)
finally:
os.close(directory_fd)
print(version)
PY
`
runSsh(host, remoteScript)
} catch (error) {
publicationError = error
throw error
} finally {
try {
runSsh(host, `set -euo pipefail
rm -rf '${staging}'
`)
} catch (cleanupError) {
if (publicationError) {
console.error(`Unable to remove remote staging path after failure: ${cleanupError.message}`)
} else {
console.warn(`Publication succeeded, but remote staging cleanup failed: ${cleanupError.message}`)
}
}
}
return { version }
}
+761
View File
@@ -0,0 +1,761 @@
// Real isolated YCode main/preload/renderer verification for native append delivery.
// The production build is loaded unchanged. A loopback Anthropic-compatible server
// holds the first response while two durable queue messages are sent as guidance.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/claude-append-app-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
await Promise.all([fs.mkdir(data, { recursive: true }), fs.mkdir(home, { recursive: true })])
const probe = require('node:net').createServer()
await new Promise((resolve, reject) => {
probe.once('error', reject)
probe.listen(0, '127.0.0.1', resolve)
})
const address = probe.address()
if (!address || typeof address === 'string') throw new Error('failed to reserve loopback port')
const appendPort = address.port
await new Promise(resolve => probe.close(resolve))
const env = {
...process.env,
YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: data,
YCODE_APPEND_PORT: String(appendPort),
YCODE_QA_OFFSCREEN: '1',
YCODE_DISABLE_GPU: '1',
YCODE_DISABLE_SYSTEM_INTEGRATION: '1',
HOME: home,
USERPROFILE: home,
DISABLE_TELEMETRY: '1',
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1'
}
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(
require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename],
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }
)
let output = ''
child.stdout.on('data', bytes => {
output += bytes
if (bytes.toString().includes('APPEND_E2E_')) process.stdout.write(bytes)
})
child.stderr.on('data', bytes => {
output += bytes
if (bytes.toString().includes('APPEND_E2E_')) process.stderr.write(bytes)
})
const timer = setTimeout(() => child.kill(), 180000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output.slice(-12000))
process.exitCode = code ? 1 : 0
})
})().catch(error => {
console.error(error)
process.exitCode = 1
})
} else {
const { app, BrowserWindow } = require('electron')
const { createServer } = require('node:http')
const { pathToFileURL } = require('node:url')
const fsSync = require('node:fs')
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(directory, 'YCODE_QA_DIRECTORY is required')
const appendPort = Number(process.env.YCODE_APPEND_PORT)
assert.ok(Number.isInteger(appendPort) && appendPort > 0, 'YCODE_APPEND_PORT is required')
const relative = path.relative(path.resolve('.tmp'), path.resolve(directory))
assert.ok(relative && !relative.startsWith('..') && !path.isAbsolute(relative), 'unsafe QA directory')
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
const workspace = path.join(home, 'YCodeWorkspace')
const modelId = 'native-append-loopback'
const modelBId = 'native-append-loopback-b'
const modelCId = 'native-append-loopback-c'
// Each profile keeps its own wire model id so the loopback server can prove
// which model actually served a request.
const wireModelA = 'claude-native-append-a'
const wireModelB = 'claude-native-append-b'
const wireModelC = 'claude-native-append-c'
// Each profile gets its own base path so the request URL alone proves which
// model served a turn, independently of the request body fields.
const modelPathA = '/m-a'
const modelPathB = '/m-b'
const modelPathC = '/m-c'
app.disableHardwareAcceleration()
app.setPath('userData', data)
app.setPath('sessionData', data)
app.setPath('home', home)
let hidingWindows = true
app.on('browser-window-created', (_event, win) => {
win.setSkipTaskbar(true)
win.on('show', () => {
if (!hidingWindows || win.isDestroyed()) return
setImmediate(() => {
if (!win.isDestroyed()) win.hide()
})
})
})
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const checks = []
const rendererErrors = []
const requests = []
const serverEvents = []
const queueTimeline = []
const openSockets = new Set()
let firstResponseGate
let firstResponseReleased = false
const firstResponse = new Promise(resolve => { firstResponseGate = resolve })
let stage = 'boot'
function setStage(value) {
stage = value
serverEvents.push({ type: 'stage', stage: value })
}
function releaseFirstResponse() {
if (firstResponseReleased) return
firstResponseReleased = true
firstResponseGate()
}
function check(label, actual, expected) {
const pass = typeof expected === 'function'
? expected(actual)
: JSON.stringify(actual) === JSON.stringify(expected)
checks.push({
label,
pass,
actual,
expected: typeof expected === 'function' ? expected.toString() : expected
})
}
function sse(res, event, value) {
res.write(`event: ${event}\ndata: ${JSON.stringify(value)}\n\n`)
}
function messageStart(res, id) {
sse(res, 'message_start', {
type: 'message_start',
message: {
id,
type: 'message',
role: 'assistant',
model: modelId,
content: [],
stop_reason: null,
stop_sequence: null,
usage: { input_tokens: 12, output_tokens: 1 }
}
})
}
function textBlock(res, text) {
sse(res, 'content_block_start', {
type: 'content_block_start',
index: 0,
content_block: { type: 'text', text: '' }
})
sse(res, 'content_block_delta', {
type: 'content_block_delta',
index: 0,
delta: { type: 'text_delta', text }
})
sse(res, 'content_block_stop', { type: 'content_block_stop', index: 0 })
}
function messageEnd(res) {
sse(res, 'message_delta', {
type: 'message_delta',
delta: { stop_reason: 'end_turn', stop_sequence: null },
usage: { output_tokens: 4 }
})
sse(res, 'message_stop', { type: 'message_stop' })
res.end()
}
function plainText(res, id, text) {
messageStart(res, id)
textBlock(res, text)
messageEnd(res)
}
function messageTexts(message) {
if (!message || typeof message !== 'object') return []
const content = message.content
if (typeof content === 'string') return [content]
if (!Array.isArray(content)) return []
return content.flatMap(block => block && typeof block === 'object' &&
block.type === 'text' && typeof block.text === 'string' ? [block.text] : [])
}
function userTexts(body) {
if (!Array.isArray(body.messages)) return []
return body.messages.flatMap(message => message?.role === 'user' ? messageTexts(message) : [])
}
async function read(source, win) {
let timer
try {
return await Promise.race([
win.webContents.executeJavaScript(source),
new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 140))), 20000)
})
])
} finally {
clearTimeout(timer)
}
}
async function waitFor(source, label, win, attempts = 500) {
for (let index = 0; index < attempts; index += 1) {
const value = await read(source, win)
if (value) return value
await pause(40)
}
throw Error('Timeout: ' + label)
}
async function waitForMain(predicate, label, attempts = 500) {
for (let index = 0; index < attempts; index += 1) {
const value = await predicate()
if (value) return value
await pause(40)
}
throw Error('Timeout: ' + label)
}
async function readQueue(win, sessionId) {
return read(`window.ycode.chat.queue.get(${JSON.stringify(sessionId)})`, win)
}
/**
* Click the send-now action of one specific queue row. The row is located by
* its rendered preview text, which is unique per queued message, so a
* concurrent send-now on another row can never be mistaken for this one.
*/
async function clickSendNowForRow(win, previewText) {
const clicked = await read(`(()=>{
const rows=[...document.querySelectorAll('.conversation-message-queue-item')];
const row=rows.find(node=>node.querySelector('.conversation-queue-preview-text')?.textContent?.includes(${JSON.stringify(previewText)}));
if(!row)return {ok:false,reason:'row-not-found',rows:rows.length};
const button=row.querySelector('[aria-label="立即发送"]');
if(!button)return {ok:false,reason:'no-button',status:row.textContent??''};
if(button.disabled)return {ok:false,reason:'disabled'};
button.click();
return {ok:true}
})()`, win)
assert.equal(clicked.ok, true, `send-now for row ${previewText}: ${JSON.stringify(clicked)}`)
}
/**
* A row leaves the queue only after the native identity is acknowledged.
* While the first model response is still gated, that acknowledgement cannot
* arrive, so the row stays visible in the delivering state.
*/
async function waitForRowStatus(win, sessionId, queueId, status) {
for (let index = 0; index < 500; index += 1) {
const snapshot = await readQueue(win, sessionId)
const item = snapshot.items.find(entry => entry.id === queueId)
if (item?.status === status) return snapshot
if (!item) {
const error = new Error(`queue row ${queueId} left the queue before reaching ${status}`)
error.snapshot = snapshot
throw error
}
await pause(40)
}
throw Error(`Timeout: queue row ${queueId} did not reach ${status}`)
}
async function setComposer(win, text) {
const set = await read(`(()=>{
const input=document.querySelector('.wb-composer__input');
if(!(input instanceof HTMLTextAreaElement))return false;
const setter=Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set;
setter.call(input,${JSON.stringify(text)});
input.dispatchEvent(new Event('input',{bubbles:true}));
input.focus();
return true
})()`, win)
assert.equal(set, true, 'composer textarea must exist')
await waitFor(`!document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)')?.disabled`,
'composer accepts ' + text, win)
}
async function clickComposerSend(win) {
const clicked = await read(`(()=>{
const button=document.querySelector('.cr-input-toolbar__send:not(.cr-send-button--stop)');
if(!button||button.disabled)return false;
button.click();
return true
})()`, win)
assert.equal(clicked, true, 'composer send button must be enabled')
}
async function queueFromUi(win, text, expectedCount) {
await setComposer(win, text)
await clickComposerSend(win)
await waitFor(`document.querySelectorAll('.conversation-message-queue-item').length===${expectedCount}`,
`queue count ${expectedCount} after ${text}`, win)
}
async function screenshot(win, name) {
win.webContents.invalidate()
await pause(120)
const image = await win.webContents.capturePage()
await fs.writeFile(path.join(directory, name + '.png'), image.toPNG())
return !image.isEmpty()
}
async function run() {
setStage('start')
const watchdog = setTimeout(() => {
fsSync.writeFileSync(path.join(directory, 'watchdog.json'), JSON.stringify({
stage,
checks,
queueTimeline,
requests: requests.map(request => ({ index: request.index, userTexts: request.userTexts, completed: request.completed, aborted: request.aborted, answer: request.answer })),
serverEvents
}, null, 2))
console.error('APPEND_E2E_ERROR watchdog timeout at stage ' + stage)
app.exit(1)
}, 150000)
watchdog.unref?.()
fsSync.mkdirSync(workspace, { recursive: true })
fsSync.writeFileSync(path.join(workspace, 'README.md'), 'native append app verification\n', 'utf8')
const server = createServer((req, res) => {
openSockets.add(req.socket)
req.socket.on('close', () => openSockets.delete(req.socket))
let raw = ''
req.on('data', chunk => { raw += chunk })
req.on('end', async () => {
let body = {}
try { body = JSON.parse(raw || '{}') } catch { body = { raw } }
if (!req.url?.includes('/messages')) {
res.writeHead(200, { 'content-type': 'application/json' })
res.end('{}')
return
}
const texts = userTexts(body)
const servedModel = req.url?.includes(modelPathB) ? 'B'
: req.url?.includes(modelPathC) ? 'C'
: req.url?.includes(modelPathA) ? 'A' : 'unknown'
const captured = {
index: requests.length + 1,
url: req.url,
body,
servedModel,
wireModel: typeof body.model === 'string' ? body.model : null,
userTexts: texts,
completed: false,
aborted: false
}
requests.push(captured)
serverEvents.push({ type: 'request', index: captured.index, texts, servedModel })
res.on('close', () => {
if (!captured.completed) {
captured.aborted = true
serverEvents.push({ type: 'aborted', index: captured.index })
}
})
res.writeHead(200, {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive'
})
res.flushHeaders()
if (captured.index === 1) await firstResponse
if (res.destroyed) return
const hasA = texts.some(text => text.includes('E2E_APPEND_A'))
const hasB = texts.some(text => text.includes('E2E_APPEND_B'))
const afterSwitch = texts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH'))
const answer = afterSwitch ? 'E2E_ANSWER_AFTER_SWITCH'
: captured.index === 1 ? 'E2E_ANSWER_ONE'
: hasA && hasB ? 'E2E_ANSWER_MERGED' : 'E2E_ANSWER_UNEXPECTED'
captured.completed = true
captured.answer = answer
serverEvents.push({ type: 'response', index: captured.index, answer })
plainText(res, `msg-native-append-${captured.index}`, answer)
})
})
const serverReady = new Promise((resolve, reject) => {
server.once('listening', resolve)
server.once('error', reject)
})
server.listen(appendPort, '127.0.0.1')
const baseUrl = `http://127.0.0.1:${appendPort}`
fsSync.mkdirSync(data, { recursive: true })
fsSync.writeFileSync(path.join(data, 'models.json'), JSON.stringify({
version: 1,
models: [
{
id: modelId,
name: 'Native Append Loopback A',
providerId: 'custom',
model: wireModelA,
baseUrl: baseUrl + modelPathA,
authMode: 'api-key',
apiKeyPlain: 'ycode-loopback-append-key',
protocol: 'anthropic',
supportsToolCall: true,
supportsImages: false,
supportsReasoning: false,
contextWindowTokens: 200000,
maxInputTokens: 180000,
maxOutputTokens: 4096,
temperature: 0,
maxToolRounds: 1
},
{
id: modelBId,
name: 'Native Append Loopback B',
providerId: 'custom',
model: wireModelB,
baseUrl: baseUrl + modelPathB,
authMode: 'api-key',
apiKeyPlain: 'ycode-loopback-append-key',
protocol: 'anthropic',
supportsToolCall: true,
supportsImages: false,
supportsReasoning: false,
contextWindowTokens: 200000,
maxInputTokens: 180000,
maxOutputTokens: 4096,
temperature: 0,
maxToolRounds: 1
},
{
id: modelCId,
name: 'Native Append Loopback C',
providerId: 'custom',
model: wireModelC,
baseUrl: baseUrl + modelPathC,
authMode: 'api-key',
apiKeyPlain: 'ycode-loopback-append-key',
protocol: 'anthropic',
supportsToolCall: true,
supportsImages: false,
supportsReasoning: false,
contextWindowTokens: 200000,
maxInputTokens: 180000,
maxOutputTokens: 4096,
temperature: 0,
maxToolRounds: 1
}
],
selectedModelId: modelId
}, null, 2), 'utf8')
fsSync.writeFileSync(path.join(data, 'config.json'), JSON.stringify({
baseUrl,
model: 'claude-native-append',
temperature: 0,
maxToolRounds: 1,
apiKeyPlain: 'ycode-loopback-append-key',
workspaceRoot: workspace,
preventSleep: false,
autoLaunch: false
}, null, 2), 'utf8')
let main
try {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await serverReady
await app.whenReady()
for (let index = 0; index < 300; index += 1) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(25)
}
assert.ok(main, 'main window')
main.webContents.on('console-message', (...args) => {
const event = args[0]
const level = event?.level ?? args[1]
const message = event?.message ?? args[2]
if (level === 'error' || level === 3) rendererErrors.push(String(message))
})
main.setSize(1336, 902)
await waitFor('!!window.ycode', 'preload bridge', main)
await waitFor(`!!document.querySelector('.conversation-list')&&!!document.querySelector('.wb-composer__input')`,
'renderer ready', main)
check('all created windows stay hidden during verification', main.isVisible(), false)
setStage('create-session')
const session = await read(`window.ycode.session.create('Native append E2E', ${JSON.stringify({
cwd: workspace,
modelId,
agentMode: 'craft',
permissionMode: 'default-sandbox'
})})`, main)
assert.ok(session?.id, 'session creation')
await read(`(()=>{
const url=new URL(location.href);
url.searchParams.set('window','task');
url.searchParams.set('taskId',${JSON.stringify(session.id)});
location.href=url.href;
return true
})()`, main).catch(() => undefined)
await waitFor(`document.querySelector('[data-session-id=${JSON.stringify(session.id)}]')?.getAttribute('data-selected')==='true'`,
'created session selected', main)
await waitFor(`!!document.querySelector('.wb-composer__input')`, 'composer after selection', main)
setStage('send-initial')
await setComposer(main, 'E2E_INITIAL')
await clickComposerSend(main)
await waitForMain(() => requests[0], 'first model request')
check('first request reaches the loopback model', requests[0].url.includes('/messages'), true)
setStage('queue-guidance')
await queueFromUi(main, 'E2E_APPEND_A', 1)
await queueFromUi(main, 'E2E_APPEND_B', 2)
const beforeSendNow = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main)
check('two queued guidance messages are visible in the composer', beforeSendNow.items.length, 2)
check('queued guidance retains both texts', beforeSendNow.items.map(item => item.payload.text), ['E2E_APPEND_A', 'E2E_APPEND_B'])
check('queue screenshot captured while the first response is active', await screenshot(main, 'append-queue'), true)
// Both guidance messages are handed to the running turn while the first
// model response is still gated. A row only leaves the queue after the
// native identity is acknowledged, so both rows must still be visible.
setStage('send-now-a')
await clickSendNowForRow(main, 'E2E_APPEND_A')
const afterSendNowA = await waitForRowStatus(main, session.id, beforeSendNow.items[0].id, 'delivering')
queueTimeline.push({ stage: 'after-send-now-a', items: afterSendNowA.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) })
check('the first guidance message is handed to the running turn', afterSendNowA.items[0].status, 'delivering')
check('the second guidance message is still queued while the first is delivering',
afterSendNowA.items[1]?.status, 'pending')
setStage('send-now-b')
await clickSendNowForRow(main, 'E2E_APPEND_B')
const afterSendNowB = await waitForRowStatus(main, session.id, beforeSendNow.items[1].id, 'delivering')
queueTimeline.push({ stage: 'after-send-now-b', items: afterSendNowB.items.map(item => ({ id: item.id, text: item.payload.text, status: item.status })) })
check('both guidance messages are delivered to the running turn concurrently',
afterSendNowB.items.map(item => item.status), ['delivering', 'delivering'])
check('no guidance row is removed before the native identity is acknowledged',
afterSendNowB.items.length, 2)
// ---- Model switch while the turn is still running.
// The active run must keep model A; only later requests may use the new choice.
setStage('model-switch-b')
const switchB = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelBId)} })`, main)
check('the session accepts model B while a run is active', switchB?.modelId, modelBId)
setStage('model-switch-c')
const switchC = await read(`window.ycode.session.updateContext(${JSON.stringify(session.id)}, { modelId: ${JSON.stringify(modelCId)} })`, main)
check('the session accepts model C while a run is active', switchC?.modelId, modelCId)
const modelSwitchRequests = requests.length
setStage('release-first-response')
releaseFirstResponse()
await waitForMain(() => requests.length >= 2 ? requests[1] : undefined, 'append model request')
setStage('await-queue-drain')
const drained = await waitForMain(async () => {
const snapshot = await readQueue(main, session.id)
return snapshot.items.length === 0 ? snapshot : undefined
}, 'acknowledged guidance leaves the durable queue')
const afterSendNow = drained
await waitFor(`document.querySelector('.chat-thread')?.textContent.includes('E2E_ANSWER_MERGED')`,
'merged answer rendered', main, 800)
await waitForMain(async () => {
const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main)
return active === null
}, 'native run completed')
check('final conversation screenshot captured', await screenshot(main, 'append-final'), true)
const messages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
const finalQueue = await read(`window.ycode.chat.queue.get(${JSON.stringify(session.id)})`, main)
const users = messages.filter(message => message.role === 'user')
const assistants = messages.filter(message => message.role === 'assistant')
const queuedIds = beforeSendNow.items.map(item => item.id)
const finalText = await read(`document.querySelector('.chat-thread')?.textContent??''`, main)
const secondTexts = requests[1].userTexts
const initialPosition = secondTexts.findIndex(text => text.includes('E2E_INITIAL'))
const aPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_A'))
const bPosition = secondTexts.findIndex(text => text.includes('E2E_APPEND_B'))
const lastUser = [...requests[1].body.messages].reverse().find(message => message?.role === 'user')
const lastUserText = messageTexts(lastUser).join('\n')
const lastUserBlocks = Array.isArray(lastUser?.content) ? lastUser.content : []
const guidanceBlocks = lastUserBlocks.filter(block => block?.type === 'text' &&
(block.text?.includes('E2E_APPEND_A') || block.text?.includes('E2E_APPEND_B')))
check('first request was not interrupted by queue guidance', requests[0].aborted, false)
check('first request completed before the append request', requests[0].completed, true)
check('the append guidance produced a second model request', requests.length >= 2, true)
check('append request contains the initial message before both guidance messages',
initialPosition >= 0 && aPosition > initialPosition && bPosition > aPosition, true)
check('both guidance messages are merged into one native user content group',
lastUserText.includes('E2E_APPEND_A') && lastUserText.includes('E2E_APPEND_B'), true)
check('the merged guidance arrives as separate text blocks in one user message',
guidanceBlocks.length >= 2, true)
check('the second model request is the append request', requests[1].answer, 'E2E_ANSWER_MERGED')
check('the merged answer is the second assistant turn', assistants.at(-1)?.content?.includes('E2E_ANSWER_MERGED'), true)
check('the initial answer remains in history', assistants.some(message => message.content?.includes('E2E_ANSWER_ONE')), true)
check('both queued user message identities persist in history',
queuedIds.every(id => users.some(message => message.id === id)), true)
check('queued user messages keep their guidance text',
queuedIds.map(id => users.find(message => message.id === id)?.content),
['E2E_APPEND_A', 'E2E_APPEND_B'])
check('both appended messages share the owning run identity',
new Set(users.filter(message => queuedIds.includes(message.id)).map(message => message.requestId)).size, 1)
check('final durable queue is empty', finalQueue.items.length, 0)
check('both guidance messages were acknowledged and removed from the queue',
beforeSendNow.items.every(item => !finalQueue.items.some(entry => entry.id === item.id)), true)
check('final renderer shows both guidance messages and the merged answer',
['E2E_APPEND_A', 'E2E_APPEND_B', 'E2E_ANSWER_MERGED'].every(text => finalText.includes(text)), true)
check('no renderer errors were logged', rendererErrors, [])
// ---- Independent message after the switch must use the last selected model.
setStage('model-switch-followup')
await setComposer(main, 'E2E_AFTER_MODEL_SWITCH')
await clickComposerSend(main)
// The follow-up message is queued while the run is active and dispatched
// afterwards, so wait for the request that actually carries its text
// instead of trusting the request count.
const followUp = await waitForMain(() => requests.find(request =>
request.index > modelSwitchRequests && request.userTexts.some(text => text.includes('E2E_AFTER_MODEL_SWITCH'))),
'independent message after the model switch')
check('the independent message after switching is served by the last selected model',
followUp.servedModel, 'C')
await waitForMain(async () => {
const list = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
return list.some(message => message.role === 'assistant' && message.modelId === modelCId &&
message.status === 'done' && String(message.content ?? '').includes('E2E_ANSWER_AFTER_SWITCH')) ? list : undefined
}, 'post-switch assistant persisted')
// The composer must also release the running state before the final captures.
await waitForMain(async () => {
const active = await read(`window.ycode.chat.activeRun(${JSON.stringify(session.id)})`, main)
return active === null
}, 'post-switch run released')
await waitFor(`!document.querySelector('.cr-send-button--stop')&&!document.querySelector('[aria-label="停止"]')`,
'composer stop control released', main)
const switchMessages = await read(`window.ycode.session.messages(${JSON.stringify(session.id)})`, main)
const switchAssistants = switchMessages.filter(message => message.role === 'assistant')
const lastAssistant = switchAssistants.at(-1)
check('the post-switch assistant records the last selected model identity',
lastAssistant?.modelId, modelCId)
check('the running turn kept the original model identity',
switchAssistants.find(message => message.content?.includes('E2E_ANSWER_ONE'))?.modelId, modelId)
check('the guidance turn kept the original model identity',
switchAssistants.find(message => message.content?.includes('E2E_ANSWER_MERGED'))?.modelId, modelId)
check('the running turn request was served by model A', requests[0].servedModel, 'A')
check('the guidance turn request was served by model A', requests[1].servedModel, 'A')
check('the post-switch request was served by model C', followUp.servedModel, 'C')
// ---- Dark and narrow captures of the real composer.
setStage('captures')
await read(`(()=>{
const key='ycode:theme';
localStorage.setItem(key,'dark');
window.dispatchEvent(new StorageEvent('storage',{key,newValue:'dark'}));
return true
})()`, main)
// The composer and every other themed surface must follow the shared
// theme state, not just the document element.
await waitFor(`document.querySelector('.chat-pane[data-cr-theme="dark"]')!==null`, 'dark theme applied to the chat pane', main)
check('the dark theme is applied to the real renderer',
await read(`document.querySelector('.chat-pane')?.getAttribute('data-cr-theme')??null`, main), 'dark')
check('dark conversation screenshot captured', await screenshot(main, 'append-final-dark'), true)
main.setSize(720, 900)
await pause(300)
check('narrow composer keeps the permission control visible and unobscured', await read(`(()=>{
const button=document.querySelector('.yc-source-permission-trigger .cr-permission-setting');
if(!button)return false;
const box=button.getBoundingClientRect();
const hit=document.elementFromPoint(box.x+box.width/2,box.y+box.height/2);
return box.width>0&&box.height>0&&box.x>=0&&box.right<=innerWidth&&!!hit&&button.contains(hit);
})()`, main), true)
check('narrow conversation screenshot captured', await screenshot(main, 'append-final-narrow'), true)
main.setSize(1336, 902)
await pause(200)
const report = {
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Production out/main, out/preload, and out/renderer; isolated Electron userData/home; local loopback Anthropic SSE; real Claude SDK/CLI; two UI-queued guidance messages sent through chat.queue.sendNow while the first response was active.',
sessionId: session.id,
requests: requests.map(request => ({
index: request.index,
url: request.url,
servedModel: request.servedModel,
wireModel: request.wireModel,
userTexts: request.userTexts,
messageRoles: Array.isArray(request.body.messages) ? request.body.messages.map(message => message?.role) : [],
completed: request.completed,
aborted: request.aborted,
answer: request.answer
})),
beforeSendNow,
afterSendNow,
queueTimeline,
finalQueue,
messages,
switchMessages,
serverEvents,
checks,
rendererErrors,
screenshots: ['append-queue.png', 'append-final.png', 'append-final-dark.png', 'append-final-narrow.png'],
limitations: [
'Native result user_message_uuids are verified indirectly through persisted guidance identities, merged request content, the second assistant segment, and an empty queue.',
'The loopback server returns plain text only; tool calls, images, cancellation, and upstream provider failures are outside this E2E scope.'
]
}
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('APPEND_E2E_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) {
console.log('APPEND_E2E_FAILURE ' + JSON.stringify(failure))
}
clearTimeout(watchdog)
app.exit(report.result === 'PASS' ? 0 : 1)
} finally {
clearTimeout(watchdog)
hidingWindows = false
// A gated SSE response keeps the socket open, so close() would wait for
// it. Destroy the sockets explicitly instead of hanging the harness.
for (const socket of openSockets) socket.destroy()
openSockets.clear()
await new Promise(resolve => server.close(() => resolve()))
}
}
run().catch(async error => {
hidingWindows = false
try {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
stage,
error: String(error),
stack: error.stack,
checks,
queueTimeline,
requests: requests.map(request => ({
index: request.index,
userTexts: request.userTexts,
completed: request.completed,
aborted: request.aborted,
answer: request.answer
})),
serverEvents,
rendererErrors
}, null, 2))
} catch { /* The failure report is best effort. */ }
console.error('APPEND_E2E_ERROR', error)
app.exit(1)
})
}
+151
View File
@@ -0,0 +1,151 @@
// Production app + real GitHub package, isolated from the user's skill registry.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/recommended-app-'))
await Promise.all(['data', 'home', 'workspace'].map(name => fs.mkdir(path.join(directory, name))))
const now = Date.now()
await fs.writeFile(path.join(directory, 'data', 'sessions.json'), JSON.stringify({ sessions: [{
id: 'skill-existing', title: 'Skill existing draft', cwd: path.join(directory, 'workspace'),
createdAt: now, updatedAt: now, messages: []
}] }))
const env = { ...process.env, YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: path.join(directory, 'data'), HOME: path.join(directory, 'home'),
USERPROFILE: path.join(directory, 'home'), YCODE_QA_OFFSCREEN: '1',
YCODE_DISABLE_GPU: '1', YCODE_DISABLE_SYSTEM_INTEGRATION: '1' }
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename], { env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] })
let output = ''
child.stdout.on('data', value => { output += value; if (String(value).includes('RECOMMENDED_APP_')) process.stdout.write(value) })
child.stderr.on('data', value => { output += value })
const timer = setTimeout(() => child.kill(), 200000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code !== 0) console.error(output.slice(-7000))
process.exitCode = code === 0 ? 0 : 1
})
})().catch(error => { console.error(error); process.exitCode = 1 })
} else {
const { app, BrowserWindow } = require('electron')
const { pathToFileURL } = require('node:url')
const directory = path.resolve(process.env.YCODE_QA_DIRECTORY)
const rel = path.relative(path.resolve('.tmp'), directory)
assert.ok(rel.startsWith('recommended-app-') && !rel.includes('..'))
const checks = [], errors = []
let stage = 'boot', win
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
const read = source => win.webContents.executeJavaScript(source)
const check = (label, value) => { assert.ok(value, label); checks.push(label) }
async function click(selector) {
const point = await read(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});if(!e)throw Error('Missing target');e.scrollIntoView({block:'nearest'});const r=e.getBoundingClientRect();const x=Math.round(r.x+r.width/2),y=Math.round(r.y+r.height/2);const hit=document.elementFromPoint(x,y);if(!e.contains(hit))throw Error('Target is covered: '+${JSON.stringify(selector)});return{x,y}})()`)
win.webContents.sendInputEvent({ type: 'mouseDown', ...point, button: 'left', clickCount: 1 })
win.webContents.sendInputEvent({ type: 'mouseUp', ...point, button: 'left', clickCount: 1 })
await pause(100)
}
async function wait(source, label, timeout = 15000) {
const end = Date.now() + timeout
while (Date.now() < end) { const value = await read(source); if (value) return value; await pause(80) }
throw Error('Timeout: ' + label)
}
async function shot(name) {
win.webContents.invalidate()
await win.webContents.capturePage()
await pause(250)
await fs.writeFile(path.join(directory, name + '.png'), (await win.webContents.capturePage()).toPNG())
}
app.on('browser-window-created', (_event, window) => {
window.setSkipTaskbar(true)
window.on('show', () => window.hide())
window.webContents.on('console-message', (event) => { if (event.level === 'error') errors.push(event.message) })
})
;(async () => {
try {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
for (let index = 0; index < 200; index++) {
win = BrowserWindow.getAllWindows().find(window => window.getTitle() === 'YCode')
if (win && !win.webContents.isLoading()) break
await pause(30)
}
check('production window loaded', !!win)
await wait('!!window.ycode?.skills?.recommendations', 'real preload')
await wait('!!document.querySelector("[data-session-id=skill-existing]")', 'original session')
await click('[data-session-id=skill-existing]')
await wait('!!document.querySelector(".chat-pane .wb-composer textarea")', 'initial composer')
await read('document.querySelector(".chat-pane .wb-composer textarea").focus()')
await win.webContents.insertText('KEEP_ORIGINAL_DRAFT')
const catalog = await read('window.ycode.skills.recommendations()')
check('fixed Archify recommendation is exposed by real IPC', catalog.some(item => item.id === 'archify' && item.commit === '25cf37080fc8b1dbe758a4efc3935b55f3327083'))
await read(`(()=>{window.__installEvents=[];window.ycode.skills.onInstallProgress(event=>window.__installEvents.push(event));window.__skillChanges=0;window.ycode.skills.onChanged(()=>window.__skillChanges++);window.dispatchEvent(new CustomEvent('ycode:open-capabilities',{detail:{tab:'skills'}}));return true})()`)
await wait('!!document.querySelector("[data-recommendation-id=archify]")', 'recommendation card')
await shot('recommendation')
stage = 'install'
// Use the real card and confirmation; no direct import and no picker stub.
await click('[data-recommendation-id=archify] [aria-label="安装 archify"]')
await wait('!!document.querySelector("[aria-label=确认安装推荐技能]")', 'install confirmation')
check('the modal host escapes the isolated capability center', await read('document.querySelector(".yc-skill-modal-host")?.parentElement===document.body'))
check('the background root is inert while the skill modal is open', await read('document.getElementById("root").inert'))
check('sidebar sticky headers are behind the global mask', await read(`(()=>{const e=document.querySelector('.conversation-section-label');if(!e)return false;const r=e.getBoundingClientRect();return !!document.elementFromPoint(r.left+5,r.top+5)?.closest('.yc-skill-modal-host')})()`))
await read('window.__sameDetail=document.querySelector(".yc-skill-detail-shell")')
await shot('confirm-full-window')
await read('document.documentElement.dataset.theme="dark"')
await pause(350); await shot('confirm-dark')
const originalSize = win.getSize()
win.setSize(720, 900); await pause(350)
check('the full-window skill detail fits a narrow viewport', await read('(()=>{const r=document.querySelector(".yc-skill-detail-shell").getBoundingClientRect();return r.left>=0&&r.right<=innerWidth&&r.top>=0&&r.bottom<=innerHeight})()'))
await shot('confirm-narrow')
win.setSize(...originalSize); await read('document.documentElement.dataset.theme="light"'); await pause(300)
await click('[aria-label=确认安装推荐技能]')
await wait(`window.__installEvents.some(event=>['installed','failed','cancelled'].includes(event.phase))`, 'native installation', 150000)
const progress = await read('window.__installEvents')
check('actual download, validation and commit phases are observed', ['downloading', 'validating', 'installing', 'installed'].every(phase => progress.some(event => event.phase === phase)))
const installed = await read('window.ycode.skills.list()')
const skill = installed.find(item => item.name === 'archify')
check('installed skill is enabled, intact and has source provenance', skill?.enabled && !skill.userModified && skill.origin?.commit === catalog[0].commit)
const document = await read(`window.ycode.skills.read(${JSON.stringify(skill.id)})`)
check('skill body and packaged CLI resources are present', document.content.includes('Archify') && document.files.includes('bin/archify.mjs') && document.files.length > 100)
check('committed mutation broadcasts a skill refresh', await read('window.__skillChanges>0'))
await wait(`document.querySelector('[data-recommendation-id=archify]')?.textContent.includes('已安装')`, 'installed card refresh')
check('installation keeps the same detail DOM mounted', await read('window.__sameDetail===document.querySelector(".yc-skill-detail-shell")'))
await wait('document.querySelector("[role=dialog] .sm-skill-detail-action-row")?.textContent.includes("去使用")', 'post-install action')
check('the avatar is a packaged offline image', await read('(()=>{const i=document.querySelector("[role=dialog] .sm-skill-detail-icon-wrap img");return i?.complete&&i.naturalWidth>0&&!i.src.startsWith("http")})()'))
await shot('installed')
stage = 'idempotency'
const again = await read(`window.ycode.skills.installRecommended('archify',{operationId:crypto.randomUUID()})`)
check('same unchanged version returns existing install', again.id === skill.id && again.origin.installedAt === skill.origin.installedAt)
await read(`window.ycode.skills.setEnabled(${JSON.stringify(skill.id)},false)`)
const disabled = await read(`window.ycode.skills.installRecommended('archify',{operationId:crypto.randomUUID()})`)
check('idempotent install does not silently re-enable disabled skill', disabled.enabled === false)
await read(`window.ycode.skills.setEnabled(${JSON.stringify(skill.id)},true)`)
stage = 'use-skill'
await wait(`!!document.querySelector('[role=dialog] [aria-label="使用推荐技能 archify"]')`, 'use installed recommendation')
await click('[role=dialog] [aria-label="使用推荐技能 archify"]')
await wait(`document.querySelector('.chat-pane .wb-composer')?.textContent.includes('archify')`, 'skill reference in the real composer')
check('using the installed recommendation fills the real composer', true)
check('use opens a clean new draft instead of overwriting the old draft', await read('!document.querySelector(".chat-pane .wb-composer").textContent.includes("KEEP_ORIGINAL_DRAFT")'))
check('the background is no longer inert after use', await read('!document.getElementById("root").inert'))
await shot('use-skill')
check('browsing, installation and use do not create persisted chat sessions', (await read('window.ycode.session.list()')).length === 1)
await click('[data-session-id=skill-existing]')
await wait('document.querySelector(".chat-pane .wb-composer textarea")?.value.includes("KEEP_ORIGINAL_DRAFT")', 'original session draft restored')
check('the original session draft remains intact after using the skill', true)
check('no renderer errors', errors.length === 0)
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify({ result: 'PASS', checks, progress, skill, files: document.files,
errors, limits: 'Real fixed GitHub package and production IPC; no skill commands or model requests executed.' }, null, 2))
console.log('RECOMMENDED_APP_PASS ' + directory)
app.quit()
} catch (error) {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({ stage, error: String(error.stack ?? error), checks, errors }, null, 2))
if (win) await shot('failure').catch(() => {})
console.error('RECOMMENDED_APP_FAIL ' + String(error))
app.exit(1)
}
})()
}
+253
View File
@@ -0,0 +1,253 @@
#!/usr/bin/env node
'use strict'
const assert = require('node:assert/strict')
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const path = require('node:path')
const { execFileSync, spawnSync } = require('node:child_process')
const ROOT = path.resolve(__dirname, '..')
const REGISTRY_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
const STAMP = new Date().toISOString().replace(/[:.]/g, '-')
const RUN_ROOT = path.join(ROOT, '.tmp', 'updates-installer-marker', STAMP)
const LATEST_REPORT = path.join(ROOT, '.tmp', 'updates-installer-marker', 'latest-report.json')
function parseRegistryValue(output, name) {
const expression = new RegExp(`^\\s*${name}\\s+REG_\\w+\\s+(.+?)\\s*$`, 'mi')
const match = expression.exec(output)
return match ? match[1].replace(/^"(.*)"$/, '$1') : null
}
function queryRegistryValue(name) {
const output = execFileSync('reg.exe', ['query', REGISTRY_KEY, '/v', name], {
encoding: 'utf8',
windowsHide: true,
timeout: 10_000,
maxBuffer: 64 * 1024
})
return parseRegistryValue(output, name)
}
function registryExecutablePath(value) {
const quoted = /^\s*"([^"]+\.exe)"/i.exec(value)
if (quoted) return quoted[1]
const plain = /^\s*(.+?\.exe)(?:\s|$|,)/i.exec(value)
return plain ? plain[1] : null
}
function relativeImport(from, to) {
return `./${path.relative(path.dirname(from), to).replace(/\\/g, '/')}`
}
function createRunnerSource(filename) {
const installer = path.join(ROOT, 'src', 'main', 'updates', 'installer.ts')
const physical = path.join(ROOT, 'src', 'main', 'updates', 'physical-files.ts')
return `
import { app } from 'electron'
import fsPromises from 'node:fs/promises'
import path from 'node:path'
import { createNsisInstaller, queryNsisRegistryValue } from ${JSON.stringify(relativeImport(filename, installer))}
import { physicalRealPath, physicalStat } from ${JSON.stringify(relativeImport(filename, physical))}
function decodeConfig() {
return JSON.parse(Buffer.from(process.env.YCODE_MARKER_CONFIG || '', 'base64').toString('utf8'))
}
async function captureError(operation) {
try {
await operation()
return null
} catch (error) {
return {
code: error && typeof error === 'object' && 'code' in error ? error.code : null,
message: error instanceof Error ? error.message : String(error)
}
}
}
async function main() {
const config = decodeConfig()
const patched = await fsPromises.stat(config.appPath)
const physicalAsar = await physicalStat(config.appPath)
const physicalResources = await physicalStat(path.join(config.installationDirectory, 'resources'))
const validAdapter = createNsisInstaller({
platform: 'win32',
isPackaged: true,
execPath: config.executablePath,
appPath: config.appPath,
cacheDirectory: config.cacheDirectory,
resolveRealPath: physicalRealPath,
readStat: physicalStat,
queryRegistryValue: queryNsisRegistryValue
})
const environment = await validAdapter.validateEnvironment(1)
const directoryFixtureAdapter = createNsisInstaller({
platform: 'win32',
isPackaged: true,
execPath: config.directoryFixture.executablePath,
appPath: config.directoryFixture.appPath,
cacheDirectory: config.cacheDirectory,
resolveRealPath: physicalRealPath,
readStat: physicalStat,
queryRegistryValue: async name => {
if (name === 'UninstallString') return '"' + config.directoryFixture.uninstallerPath + '" /currentuser'
if (name === 'DisplayIcon') return config.directoryFixture.executablePath + ',0'
return null
}
})
const directoryError = await captureError(() => directoryFixtureAdapter.validateEnvironment(0))
const wrongRegistryAdapter = createNsisInstaller({
platform: 'win32',
isPackaged: true,
execPath: config.executablePath,
appPath: config.appPath,
cacheDirectory: config.cacheDirectory,
resolveRealPath: physicalRealPath,
readStat: physicalStat,
queryRegistryValue: async name => {
if (name === 'UninstallString') return '"C:\\\\Wrong\\\\Uninstall YCode.exe" /currentuser'
if (name === 'DisplayIcon') return config.executablePath + ',0'
return null
}
})
const wrongRegistryError = await captureError(() => wrongRegistryAdapter.validateEnvironment(0))
const result = {
electronVersion: process.versions.electron,
nodeVersion: process.versions.node,
installationDirectory: environment.installationDirectory,
executablePath: environment.executablePath,
appPath: config.appPath,
patchedFs: { isFile: patched.isFile(), isDirectory: patched.isDirectory() },
physicalAsar: { isFile: physicalAsar.isFile(), isDirectory: physicalAsar.isDirectory() },
physicalResources: { isFile: physicalResources.isFile(), isDirectory: physicalResources.isDirectory() },
directoryError,
wrongRegistryError,
installerLaunched: false
}
console.log('YCODE_MARKER_RESULT=' + Buffer.from(JSON.stringify(result)).toString('base64'))
app.exit(0)
}
main().catch(error => {
console.error(error)
app.exit(1)
})
`
}
async function main() {
if (process.platform !== 'win32') throw new Error('Installer marker verification requires Windows')
await fsp.mkdir(RUN_ROOT, { recursive: true })
const uninstallString = queryRegistryValue('UninstallString')
const displayIcon = queryRegistryValue('DisplayIcon')
const displayVersion = queryRegistryValue('DisplayVersion')
const uninstallerPath = uninstallString ? registryExecutablePath(uninstallString) : null
const executablePath = displayIcon
? registryExecutablePath(displayIcon.replace(/,\s*-?\d+\s*$/, ''))
: null
if (!uninstallerPath || !executablePath) throw new Error('Current HKCU YCode registry paths are incomplete')
const installationDirectory = path.dirname(executablePath)
if (path.dirname(uninstallerPath).toLowerCase() !== installationDirectory.toLowerCase()) {
throw new Error('Current HKCU uninstaller and icon paths disagree')
}
const appPath = path.join(installationDirectory, 'resources', 'app.asar')
const cacheDirectory = path.join(RUN_ROOT, 'cache')
await fsp.mkdir(cacheDirectory, { recursive: true })
const directoryFixtureRoot = path.join(RUN_ROOT, 'directory-marker-fixture')
const directoryFixtureResources = path.join(directoryFixtureRoot, 'resources')
await fsp.mkdir(path.join(directoryFixtureResources, 'app.asar'), { recursive: true })
const directoryFixtureExe = path.join(directoryFixtureRoot, 'YCode.exe')
const directoryFixtureUninstaller = path.join(directoryFixtureRoot, 'Uninstall YCode.exe')
await fsp.writeFile(directoryFixtureExe, 'fixture')
await fsp.writeFile(directoryFixtureUninstaller, 'fixture')
const runnerEntry = path.join(RUN_ROOT, 'runner-entry.ts')
const runnerOutput = path.join(RUN_ROOT, 'runner.cjs')
await fsp.writeFile(runnerEntry, createRunnerSource(runnerEntry), 'utf8')
const esbuild = path.join(ROOT, 'node_modules', 'esbuild', 'bin', 'esbuild')
execFileSync(process.execPath, [
esbuild,
runnerEntry,
'--bundle',
'--platform=node',
'--format=cjs',
'--external:electron',
`--outfile=${runnerOutput}`,
'--log-level=warning'
], { cwd: ROOT, stdio: 'inherit' })
const electronBinary = require('electron')
const config = {
installationDirectory,
executablePath,
appPath,
cacheDirectory,
directoryFixture: {
executablePath: directoryFixtureExe,
uninstallerPath: directoryFixtureUninstaller,
appPath: path.join(directoryFixtureResources, 'app.asar')
}
}
const env = {
...process.env,
YCODE_MARKER_CONFIG: Buffer.from(JSON.stringify(config)).toString('base64')
}
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_NO_ASAR
const child = spawnSync(electronBinary, [runnerOutput], {
cwd: RUN_ROOT,
env,
encoding: 'utf8',
windowsHide: true,
timeout: 45_000,
maxBuffer: 4 * 1024 * 1024
})
if (child.error) throw child.error
if (child.status !== 0) {
throw new Error(`Electron marker runner failed (${child.status}): ${child.stderr || child.stdout}`)
}
const marker = child.stdout.split(/\r?\n/).find(line => line.startsWith('YCODE_MARKER_RESULT='))
if (!marker) throw new Error(`Electron marker runner returned no result: ${child.stderr || child.stdout}`)
const result = JSON.parse(Buffer.from(marker.slice('YCODE_MARKER_RESULT='.length), 'base64').toString('utf8'))
assert.equal(result.installationDirectory.toLowerCase(), installationDirectory.toLowerCase())
assert.equal(result.patchedFs.isFile, false)
assert.equal(result.patchedFs.isDirectory, true)
assert.equal(result.physicalAsar.isFile, true)
assert.equal(result.physicalAsar.isDirectory, false)
assert.equal(result.physicalResources.isFile, false)
assert.equal(result.physicalResources.isDirectory, true)
assert.equal(result.directoryError?.code, 'installer-marker')
assert.match(result.directoryError?.message || '', /app\.asar/)
assert.equal(result.wrongRegistryError?.code, 'installer-registry')
assert.equal(result.installerLaunched, false)
const report = {
schemaVersion: 1,
passed: true,
registryDisplayVersion: displayVersion,
uninstallerPath,
executablePath,
appPath,
runnerOutput,
result
}
const reportPath = path.join(RUN_ROOT, 'report.json')
await fsp.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
await fsp.mkdir(path.dirname(LATEST_REPORT), { recursive: true })
await fsp.writeFile(LATEST_REPORT, `${JSON.stringify(report, null, 2)}\n`, 'utf8')
console.log(`PASS installer marker: patched fs directory, original-fs file, directory and registry rejection`)
console.log(`report: ${reportPath}`)
}
main().catch(error => {
console.error(error)
process.exitCode = 1
})
+666
View File
@@ -0,0 +1,666 @@
#!/usr/bin/env node
'use strict'
const assert = require('node:assert/strict')
const crypto = require('node:crypto')
const fs = require('node:fs')
const fsp = require('node:fs/promises')
const http = require('node:http')
const net = require('node:net')
const os = require('node:os')
const path = require('node:path')
const { execFileSync, spawn } = require('node:child_process')
const ROOT = path.resolve(__dirname, '..')
const MANIFEST_URL = 'http://192.168.10.6/ycode/releases/latest.json'
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
function readExpectedVersion() {
const args = process.argv.slice(2)
let value = null
for (let index = 0; index < args.length; index += 1) {
const argument = args[index]
if (argument === '--expected-version') {
value = args[++index]
} else if (argument.startsWith('--expected-version=')) {
value = argument.slice('--expected-version='.length)
} else {
throw new Error(`Unknown argument: ${argument}`)
}
}
if (value === null) value = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version
if (typeof value !== 'string' || !SEMVER_RE.test(value)) throw new Error(`Invalid expected version: ${value}`)
return value
}
const EXPECTED_VERSION = readExpectedVersion()
const PACKAGED_EXE = path.resolve(process.env.YCODE_QA_PACKAGED_EXE || path.join(ROOT, 'release', 'win-unpacked', 'YCode.exe'))
const PUBLIC_KEY = path.join(ROOT, 'resources', 'updates', 'public-key.pem')
const OUTPUT_ROOT = path.join(ROOT, '.tmp', 'updates-live')
const STAMP = new Date().toISOString().replace(/[:.]/g, '-')
const RUN_ROOT = path.join(OUTPUT_ROOT, STAMP)
const REPORT_PATH = path.join(RUN_ROOT, 'report.json')
const LATEST_REPORT_PATH = path.join(OUTPUT_ROOT, 'latest-report.json')
const SCREENSHOT_PATH = path.join(RUN_ROOT, 'production-boot.png')
const REGISTRY_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
const sleep = milliseconds => new Promise(resolve => setTimeout(resolve, milliseconds))
let activeBootChild = null
async function handleInterrupt(signal) {
await stopExactChild(activeBootChild)
process.exit(signal === 'SIGINT' ? 130 : 143)
}
process.once('SIGINT', () => void handleInterrupt('SIGINT'))
process.once('SIGTERM', () => void handleInterrupt('SIGTERM'))
function sha256(value) {
return crypto.createHash('sha256').update(value).digest('hex')
}
async function sha256File(filename) {
const hash = crypto.createHash('sha256')
for await (const chunk of fs.createReadStream(filename)) hash.update(chunk)
return hash.digest('hex')
}
async function writeJsonAtomic(filename, value) {
await fsp.mkdir(path.dirname(filename), { recursive: true })
const temporary = `${filename}.${process.pid}.tmp`
await fsp.writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
await fsp.rename(temporary, filename)
}
function errorDetails(error) {
if (error instanceof Error) return { name: error.name, message: error.message, stack: error.stack }
return { name: 'Error', message: String(error) }
}
async function fetchBytes(url, maximum = 1024 * 1024, timeoutMs = 30_000) {
let current = new URL(url)
const origin = current.origin
for (let redirects = 0; redirects <= 5; redirects += 1) {
const response = await fetch(current, {
redirect: 'manual',
cache: 'no-store',
signal: AbortSignal.timeout(timeoutMs)
})
if ([301, 302, 303, 307, 308].includes(response.status)) {
const location = response.headers.get('location')
await response.body?.cancel().catch(() => undefined)
if (!location || redirects === 5) throw new Error('Manifest redirect chain is invalid')
const next = new URL(location, current)
if (next.origin !== origin) throw new Error('Manifest redirect left the configured origin')
current = next
continue
}
if (!response.ok) throw new Error(`Manifest request failed with HTTP ${response.status}`)
const declared = Number(response.headers.get('content-length') || 0)
if (declared > maximum) throw new Error('Manifest response exceeds the size limit')
const bytes = Buffer.from(await response.arrayBuffer())
if (bytes.length > maximum) throw new Error('Manifest response exceeds the size limit')
return bytes
}
throw new Error('Manifest redirect chain is invalid')
}
async function reserveLoopbackPort() {
const server = net.createServer()
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', resolve)
})
const address = server.address()
await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve()))
if (!address || typeof address === 'string') throw new Error('Unable to reserve a debug port')
return address.port
}
function getJson(url, timeoutMs = 2_000) {
return new Promise((resolve, reject) => {
const request = http.get(url, { timeout: timeoutMs, family: 4 }, response => {
const chunks = []
response.on('data', chunk => chunks.push(chunk))
response.on('end', () => {
if (response.statusCode !== 200) {
reject(new Error(`HTTP ${response.statusCode} from ${url}`))
return
}
try {
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')))
} catch (error) {
reject(error)
}
})
})
request.on('timeout', () => request.destroy(new Error('CDP endpoint timed out')))
request.on('error', reject)
})
}
async function waitForCdpPage(port, child) {
const deadline = Date.now() + 45_000
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Packaged app exited early with code ${child.exitCode}`)
try {
const targets = await getJson(`http://127.0.0.1:${port}/json/list`)
const page = targets.find(target => target.type === 'page'
&& typeof target.webSocketDebuggerUrl === 'string'
&& (/\/index\.html(?:[?#]|$)/.test(target.url) || /\/$/.test(target.url)))
if (page) return page
} catch {
// Chromium's endpoint is not ready yet.
}
await sleep(250)
}
throw new Error('Timed out waiting for the packaged app renderer target')
}
function openWebSocket(url) {
const WebSocketCtor = globalThis.WebSocket || require('ws')
return new Promise((resolve, reject) => {
const socket = new WebSocketCtor(url)
const timer = setTimeout(() => reject(new Error('CDP WebSocket open timed out')), 15_000)
const cleanup = () => {
clearTimeout(timer)
socket.removeEventListener?.('open', onOpen)
socket.removeEventListener?.('error', onError)
}
const onOpen = () => {
cleanup()
resolve(socket)
}
const onError = error => {
cleanup()
reject(error instanceof Error ? error : new Error('CDP WebSocket failed'))
}
socket.addEventListener('open', onOpen)
socket.addEventListener('error', onError)
})
}
class CdpClient {
constructor(socket) {
this.socket = socket
this.nextId = 1
this.pending = new Map()
socket.addEventListener('message', event => {
let message
try {
const text = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8')
message = JSON.parse(text)
} catch {
return
}
if (!message.id) return
const pending = this.pending.get(message.id)
if (!pending) return
this.pending.delete(message.id)
clearTimeout(pending.timer)
if (message.error) pending.reject(new Error(`${message.error.message || 'CDP error'} (${message.error.code})`))
else pending.resolve(message.result)
})
socket.addEventListener('close', () => {
for (const pending of this.pending.values()) {
clearTimeout(pending.timer)
pending.reject(new Error('CDP WebSocket closed'))
}
this.pending.clear()
})
}
static async connect(url) {
return new CdpClient(await openWebSocket(url))
}
request(method, params = {}, timeoutMs = 30_000) {
const id = this.nextId
this.nextId += 1
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pending.delete(id)
reject(new Error(`CDP request timed out: ${method}`))
}, timeoutMs)
this.pending.set(id, { resolve, reject, timer })
this.socket.send(JSON.stringify({ id, method, params }))
})
}
async evaluate(expression) {
const result = await this.request('Runtime.evaluate', {
expression,
awaitPromise: true,
returnByValue: true,
userGesture: false
})
if (result.exceptionDetails) {
const description = result.exceptionDetails.exception?.description
|| result.exceptionDetails.text
|| 'Renderer evaluation failed'
throw new Error(description)
}
return result.result?.value
}
close() {
try {
this.socket.close()
} catch {
// The renderer may already be gone.
}
}
}
async function waitForUpdatesApi(client, child) {
const deadline = Date.now() + 30_000
while (Date.now() < deadline) {
if (child.exitCode !== null) throw new Error(`Packaged app exited early with code ${child.exitCode}`)
try {
const ready = await client.evaluate(
"typeof window.ycode?.updates?.getState === 'function' && document.readyState === 'complete'"
)
if (ready === true) return
} catch {
// React/preload may still be starting.
}
await sleep(250)
}
throw new Error('Timed out waiting for window.ycode.updates')
}
const UPDATER_INSPECTION_EXPRESSION = `(async () => {
const api = window.ycode?.updates
if (!api) throw new Error('window.ycode.updates is unavailable')
const apiMethods = ['getState', 'check', 'download', 'install', 'dismiss', 'openDownloadPage', 'openLog', 'onState']
.filter(name => typeof api[name] === 'function')
const before = await api.getState()
const events = []
const unsubscribe = api.onState(state => events.push(state))
let after
try {
after = await api.check()
const deadline = Date.now() + 15000
while (Date.now() < deadline) {
if (events.some(state => state.status === 'up-to-date')) break
if (events.some(state => state.status === 'error' || state.status === 'unsupported')) break
await new Promise(resolve => setTimeout(resolve, 100))
}
await new Promise(resolve => setTimeout(resolve, 500))
} finally {
unsubscribe()
}
return { apiMethods, before, after, events }
})()`
async function captureScreenshot(client, filename) {
await client.request('Page.enable')
const screenshot = await client.request('Page.captureScreenshot', {
format: 'png',
fromSurface: true,
captureBeyondViewport: false
})
await fsp.writeFile(filename, Buffer.from(screenshot.data, 'base64'))
}
async function stopExactChild(child) {
if (!child || child.exitCode !== null || child.signalCode !== null) return
const exited = new Promise(resolve => child.once('exit', resolve))
child.kill()
await Promise.race([exited, sleep(5_000)])
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL')
await Promise.race([exited, sleep(2_000)])
}
}
async function runProductionBoot(expectedManifest) {
if (!fs.existsSync(PACKAGED_EXE)) throw new Error(`Packaged executable is missing: ${PACKAGED_EXE}`)
const port = await reserveLoopbackPort()
const userData = path.join(RUN_ROOT, 'production-boot-user-data')
await fsp.mkdir(userData, { recursive: true })
const stdoutPath = path.join(RUN_ROOT, 'production-boot.stdout.log')
const stderrPath = path.join(RUN_ROOT, 'production-boot.stderr.log')
const stdout = fs.createWriteStream(stdoutPath, { flags: 'w' })
const stderr = fs.createWriteStream(stderrPath, { flags: 'w' })
const env = {
...process.env,
YCODE_USER_DATA_DIR: userData,
YCODE_SKIP_KERNEL_DATA_RESET: '1',
YCODE_QA_OFFSCREEN: '1'
}
delete env.ELECTRON_RENDERER_URL
delete env.YCODE_QA_DIRECTORY
for (const key of ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'YCODE_API_KEY', 'CODEBUDDY_API_KEY']) delete env[key]
const child = spawn(PACKAGED_EXE, [
`--remote-debugging-port=${port}`,
'--remote-debugging-address=127.0.0.1'
], {
cwd: RUN_ROOT,
env,
windowsHide: true,
stdio: ['ignore', 'pipe', 'pipe']
})
child.stdout.pipe(stdout)
child.stderr.pipe(stderr)
activeBootChild = child
let client
let inspection
let screenshotWritten = false
try {
if (!child.pid) throw new Error('Packaged app did not expose a child PID')
const page = await waitForCdpPage(port, child)
client = await CdpClient.connect(page.webSocketDebuggerUrl)
await waitForUpdatesApi(client, child)
inspection = await client.evaluate(UPDATER_INSPECTION_EXPRESSION)
await captureScreenshot(client, SCREENSHOT_PATH)
screenshotWritten = true
assert.equal(inspection.before.currentVersion, EXPECTED_VERSION)
assert.equal(inspection.after.currentVersion, EXPECTED_VERSION)
assert.equal(inspection.after.status, 'up-to-date')
assert.equal(inspection.after.lastExplicit, true)
assert.equal(inspection.apiMethods.length, 8)
assert.equal(inspection.events.some(state => state.status === 'checking'), true)
assert.equal(inspection.events.some(state => state.status === 'up-to-date'), true)
assert.equal(inspection.events.some(state => ['downloading', 'ready', 'installing'].includes(state.status)), false)
assert.equal(inspection.events.every(state => state.currentVersion === EXPECTED_VERSION), true)
return {
mode: 'actual-production-packaged-boot',
executable: PACKAGED_EXE,
childPid: child.pid,
debugPort: port,
isolatedUserData: userData,
expectedServerVersion: expectedManifest.version,
apiMethods: inspection.apiMethods,
before: inspection.before,
after: inspection.after,
sharedIpcEvents: inspection.events,
screenshot: screenshotWritten ? SCREENSHOT_PATH : null,
stdoutLog: stdoutPath,
stderrLog: stderrPath,
installerOrUpgradeRequested: false,
passed: true
}
} finally {
client?.close()
await stopExactChild(child)
if (activeBootChild === child) activeBootChild = null
stdout.end()
stderr.end()
}
}
function createRuntimeEntry(filename) {
const relative = value => `./${path.relative(path.dirname(filename), value).replace(/\\/g, '/')}`
return [
`export { UpdateManager } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/manager.ts')))}`,
`export { FileUpdatePersistence } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/storage.ts')))}`,
`export { verifySignedManifest } from ${JSON.stringify(relative(path.join(ROOT, 'src/main/updates/manifest.ts')))}`,
''
].join('\n')
}
function bundleActualManager(runtimeDirectory) {
fs.mkdirSync(runtimeDirectory, { recursive: true })
const entry = path.join(runtimeDirectory, 'entry.ts')
const output = path.join(runtimeDirectory, 'updater-runtime.cjs')
fs.writeFileSync(entry, createRuntimeEntry(entry), 'utf8')
const esbuild = path.join(ROOT, 'node_modules', 'esbuild', 'bin', 'esbuild')
if (!fs.existsSync(esbuild)) throw new Error(`esbuild is missing: ${esbuild}`)
execFileSync(process.execPath, [
esbuild,
entry,
'--bundle',
'--platform=node',
'--format=cjs',
'--target=node22',
`--outfile=${output}`,
'--log-level=warning'
], { cwd: ROOT, stdio: 'inherit' })
return { entry, output, runtime: require(output) }
}
function forbiddenInstaller() {
const calls = { validateEnvironment: 0, verifyInstaller: 0, launch: 0 }
const fail = name => {
calls[name] += 1
throw new Error('Live verification is download-only; installer access is forbidden')
}
return {
calls,
adapter: {
validateEnvironment: async () => fail('validateEnvironment'),
verifyInstaller: async () => fail('verifyInstaller'),
launch: async () => fail('launch')
}
}
}
async function runSimulatedPredecessorDownload(expectedManifest, runtime, publicKeyPem) {
const directory = path.join(RUN_ROOT, 'simulated-predecessor')
const cacheDirectory = path.join(directory, 'cache')
const persistence = new runtime.FileUpdatePersistence(path.join(directory, 'state.json'))
const forbidden = forbiddenInstaller()
const callbacks = { beforeInstall: 0, quitForUpdate: 0, installFailed: 0 }
const events = []
const manager = new runtime.UpdateManager({
currentVersion: '0.0.1',
manifestUrl: MANIFEST_URL,
cacheDirectory,
logFile: path.join(directory, 'updater.log'),
persistence,
publicKey: async () => publicKeyPem,
supported: true,
installer: forbidden.adapter,
beforeInstall: async () => {
callbacks.beforeInstall += 1
throw new Error('beforeInstall is forbidden in live download verification')
},
quitForUpdate: async () => {
callbacks.quitForUpdate += 1
throw new Error('quitForUpdate is forbidden in live download verification')
},
installFailed: () => {
callbacks.installFailed += 1
},
autoDownload: false,
manifestTimeoutMs: 60_000,
downloadTimeoutMs: 20 * 60_000
})
let lastLoggedProgress = -1
const unsubscribe = manager.subscribe(state => {
events.push(state)
if (state.status === 'downloading' && typeof state.progress === 'number') {
const bucket = Math.floor(state.progress / 10) * 10
if (bucket > lastLoggedProgress) {
lastLoggedProgress = bucket
console.log(`simulated predecessor download: ${state.progress}%`)
}
}
})
try {
const checked = await manager.check(true)
assert.equal(checked.status, 'available')
assert.equal(checked.version, expectedManifest.version)
const downloaded = await manager.download(true)
assert.equal(downloaded.status, 'ready')
assert.equal(downloaded.progress, 100)
assert.equal(downloaded.currentVersion, '0.0.1')
assert.equal(downloaded.version, expectedManifest.version)
assert.equal(downloaded.lastExplicit, true)
const filename = `YCode-${expectedManifest.version}-Setup.exe`
const cachedPath = path.join(cacheDirectory, filename)
const info = await fsp.stat(cachedPath)
const digest = await sha256File(cachedPath)
assert.equal(info.isFile(), true)
assert.equal(info.size, expectedManifest.size)
assert.equal(digest, expectedManifest.sha256)
assert.equal(path.basename(cachedPath), filename)
const persisted = await persistence.load()
assert.equal(persisted.unfinishedVersion, undefined)
assert.equal(persisted.target, undefined)
assert.deepEqual(forbidden.calls, { validateEnvironment: 0, verifyInstaller: 0, launch: 0 })
assert.deepEqual(callbacks, { beforeInstall: 0, quitForUpdate: 0, installFailed: 0 })
assert.equal(events.some(state => state.status === 'downloading'), true)
return {
mode: 'simulated-predecessor-real-network-download',
simulatedPredecessorVersion: '0.0.1',
actualApplicationVersionChanged: false,
manifestUrl: MANIFEST_URL,
expectedVersion: expectedManifest.version,
state: downloaded,
observedDownloading: true,
cachedPath,
size: info.size,
sha256: digest,
installerAdapterCalls: forbidden.calls,
installCallbacks: callbacks,
passed: true
}
} finally {
unsubscribe()
manager.dispose()
}
}
function readRealUserState() {
let registryFingerprint = null
let registryEntryPresent = false
try {
const output = execFileSync('reg.exe', ['query', REGISTRY_KEY], {
encoding: 'utf8',
windowsHide: true,
timeout: 10_000,
maxBuffer: 64 * 1024
})
registryFingerprint = sha256(output)
registryEntryPresent = true
} catch {
// The current user may not have a production installation.
}
let installedExeFingerprint = null
let installedExePresent = false
const installedExe = path.join(process.env.LOCALAPPDATA || '', 'Programs', 'YCode', 'YCode.exe')
try {
const info = fs.statSync(installedExe)
installedExePresent = true
installedExeFingerprint = sha256(`${installedExe}|${info.size}|${info.mtimeMs}`)
} catch {
// Optional read-only check only.
}
return { registryEntryPresent, registryFingerprint, installedExePresent, installedExeFingerprint }
}
async function main() {
await fsp.mkdir(RUN_ROOT, { recursive: true })
const realUserStateBefore = readRealUserState()
const report = {
schemaVersion: 1,
startedAt: new Date().toISOString(),
finishedAt: null,
manifestUrl: MANIFEST_URL,
expectedVersion: EXPECTED_VERSION,
realApplicationUpgradePerformed: false,
installerLaunched: false,
checks: {
productionPackagedBoot: null,
simulatedPredecessorDownload: null
},
realUserStateBefore,
realUserStateAfter: null,
limitations: [
'Production boot verifies only packaged updater IPC and up-to-date state.',
'Installer download uses a simulated predecessor version 0.0.1 manager.',
'No real application upgrade, installer launch, deployment, or replacement is performed.'
]
}
let exitCode = 0
try {
const publicKeyPem = await fsp.readFile(PUBLIC_KEY, 'utf8')
const runtime = bundleActualManager(path.join(RUN_ROOT, 'runtime')).runtime
const manifestBytes = await fetchBytes(MANIFEST_URL)
const manifest = runtime.verifySignedManifest(manifestBytes, {
manifestUrl: MANIFEST_URL,
publicKeyPem
})
assert.equal(manifest.payload.version, EXPECTED_VERSION)
const platform = manifest.payload.platforms['windows-x64']
const expectedManifest = {
version: manifest.payload.version,
url: platform.url,
size: platform.size,
sha256: platform.sha256
}
report.verifiedManifest = expectedManifest
try {
report.checks.productionPackagedBoot = await runProductionBoot(expectedManifest)
} catch (error) {
exitCode = 1
report.checks.productionPackagedBoot = {
mode: 'actual-production-packaged-boot',
passed: false,
error: errorDetails(error)
}
}
try {
report.checks.simulatedPredecessorDownload = await runSimulatedPredecessorDownload(
expectedManifest,
runtime,
publicKeyPem
)
} catch (error) {
exitCode = 1
report.checks.simulatedPredecessorDownload = {
mode: 'simulated-predecessor-real-network-download',
simulatedPredecessorVersion: '0.0.1',
actualApplicationVersionChanged: false,
passed: false,
error: errorDetails(error)
}
}
} catch (error) {
exitCode = 1
report.preflightError = errorDetails(error)
} finally {
report.realUserStateAfter = readRealUserState()
report.realUserStateUnchanged = JSON.stringify(report.realUserStateBefore) === JSON.stringify(report.realUserStateAfter)
report.finishedAt = new Date().toISOString()
report.passed = exitCode === 0
await writeJsonAtomic(REPORT_PATH, report)
await writeJsonAtomic(LATEST_REPORT_PATH, report)
console.log(`report: ${REPORT_PATH}`)
if (fs.existsSync(SCREENSHOT_PATH)) console.log(`screenshot: ${SCREENSHOT_PATH}`)
}
process.exitCode = exitCode
}
main().catch(async error => {
console.error(error)
process.exitCode = 1
try {
await fsp.mkdir(RUN_ROOT, { recursive: true })
await writeJsonAtomic(REPORT_PATH, {
schemaVersion: 1,
startedAt: null,
finishedAt: new Date().toISOString(),
manifestUrl: MANIFEST_URL,
expectedVersion: EXPECTED_VERSION,
fatalError: errorDetails(error),
passed: false,
realApplicationUpgradePerformed: false,
installerLaunched: false
})
} catch {
// Preserve the original failure.
}
})
+560
View File
@@ -0,0 +1,560 @@
// Real isolated YCode App/preload/renderer verification for the update UI.
// Production out/main/preload are loaded unchanged. Only the harness replaces updates:* IPC handlers
// so checking, download progress, failure/retry, dismissal and install can be exercised deterministically.
const fs = require('node:fs/promises')
const path = require('node:path')
const assert = require('node:assert/strict')
if (!process.versions.electron) {
;(async () => {
const directory = await fs.mkdtemp(path.resolve('.tmp/updates-ui-'))
const data = path.join(directory, 'data')
const home = path.join(directory, 'home')
await Promise.all([fs.mkdir(data), fs.mkdir(home)])
const env = {
...process.env,
YCODE_QA_DIRECTORY: directory,
YCODE_USER_DATA_DIR: data,
YCODE_DISABLE_GPU: '1',
YCODE_DISABLE_SYSTEM_INTEGRATION: '1'
}
delete env.ELECTRON_RUN_AS_NODE
delete env.ELECTRON_RENDERER_URL
const child = require('node:child_process').spawn(
require('electron'),
['--no-sandbox', '--disable-gpu-sandbox', __filename],
{ env, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }
)
let output = ''
child.stdout.on('data', bytes => {
output += bytes
if (bytes.toString().includes('UPDATES_UI_')) process.stdout.write(bytes)
})
child.stderr.on('data', bytes => {
output += bytes
if (bytes.toString().includes('UPDATES_UI_')) process.stderr.write(bytes)
})
const timer = setTimeout(() => child.kill(), 300000)
child.once('exit', async code => {
clearTimeout(timer)
await fs.writeFile(path.join(directory, 'process.log'), output)
console.log(JSON.stringify({ directory, exitCode: code }))
if (code) console.error(output.slice(-8000))
process.exitCode = code ? 1 : 0
})
})().catch(error => {
console.error(error)
process.exitCode = 1
})
} else {
const { app, BrowserWindow, ipcMain } = require('electron')
const { pathToFileURL } = require('node:url')
const packageVersion = require('../package.json').version
const directory = process.env.YCODE_QA_DIRECTORY
assert.ok(path.relative(path.resolve('.tmp'), directory).startsWith('updates-ui-'))
app.disableHardwareAcceleration()
app.setPath('userData', path.join(directory, 'data'))
app.setPath('sessionData', path.join(directory, 'data'))
app.setPath('home', path.join(directory, 'home'))
let main
let settings
let phase = 'boot'
const checks = []
const snapshots = {}
const rendererErrors = []
const actionCounts = {
check: 0,
download: 0,
install: 0,
dismiss: 0,
downloadPage: 0,
log: 0
}
let updateState = {
status: 'idle',
currentVersion: packageVersion,
lastExplicit: false
}
const pause = ms => new Promise(resolve => setTimeout(resolve, ms))
function broadcastUpdateState() {
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed()) win.webContents.send('updates:state', { ...updateState })
}
}
function setUpdateState(patch, replace = false) {
updateState = {
...(replace ? { currentVersion: updateState.currentVersion } : updateState),
...patch,
lastChecked: new Date().toISOString()
}
broadcastUpdateState()
return { ...updateState }
}
const nativeHandle = ipcMain.handle.bind(ipcMain)
const absorbedUpdateHandlers = []
ipcMain.handle = (channel, listener) => {
if (channel.startsWith('updates:')) {
absorbedUpdateHandlers.push(channel)
return
}
return nativeHandle(channel, listener)
}
nativeHandle('updates:get-state', () => ({ ...updateState }))
nativeHandle('updates:check', () => {
actionCounts.check += 1
return setUpdateState({ status: 'checking', error: undefined, lastExplicit: true })
})
nativeHandle('updates:download', () => {
actionCounts.download += 1
return setUpdateState({ status: 'downloading', progress: 37, error: undefined, lastExplicit: true })
})
nativeHandle('updates:install', () => {
actionCounts.install += 1
return setUpdateState({ status: 'installing', error: undefined, lastExplicit: true })
})
nativeHandle('updates:dismiss', () => {
actionCounts.dismiss += 1
return setUpdateState({
dismissedVersion: updateState.version,
status: updateState.status === 'installing' ? 'ready' : updateState.status
})
})
nativeHandle('updates:open-download-page', () => {
actionCounts.downloadPage += 1
})
nativeHandle('updates:open-log', () => {
actionCounts.log += 1
})
async function read(source, win = main) {
let timer
try {
return await Promise.race([
win.webContents.executeJavaScript(source),
new Promise((_, reject) => {
timer = setTimeout(() => reject(Error('Renderer timeout: ' + source.slice(0, 120))), 15000)
})
])
} catch (error) {
throw Error(error.message + ' - ' + source.slice(0, 240))
} finally {
clearTimeout(timer)
}
}
async function wait(source, label, win = main) {
for (let index = 0; index < 300; index += 1) {
const value = await read(source, win)
if (value) return value
await pause(35)
}
throw Error('Timeout: ' + label)
}
async function step(label) {
phase = label
await fs.writeFile(path.join(directory, 'phase.txt'), label)
}
function check(label, actual, expected) {
const pass = typeof expected === 'function'
? expected(actual)
: JSON.stringify(actual) === JSON.stringify(expected)
checks.push({
label,
pass,
actual,
expected: typeof expected === 'function' ? expected.toString() : expected
})
}
function observe(win) {
win.webContents.on('console-message', (_event, level, message) => {
if (level >= 3) rendererErrors.push({ windowId: win.id, message })
})
}
async function shot(name, win = main) {
const image = await win.webContents.capturePage()
await fs.writeFile(path.join(directory, name + '.png'), image.toPNG())
}
async function click(selector, win = main) {
const clicked = await read(`(()=>{
const node=document.querySelector(${JSON.stringify(selector)});
if(!node)return false;
node.click();
return true
})()`, win)
assert.equal(clicked, true, 'click target: ' + selector)
await pause(100)
}
async function openSettings(section) {
await read(`window.ycode.app.openSettings(${JSON.stringify(section)})`)
for (let index = 0; index < 240; index += 1) {
settings = BrowserWindow.getAllWindows().find(win => win !== main)
if (settings && !settings.webContents.isLoading()) break
await pause(25)
}
assert.ok(settings, 'settings window')
await wait('!!document.querySelector(".settings-window-root")', 'settings root', settings)
await wait('!!document.querySelector(".help-feedback-panel")', 'about panel', settings)
await pause(300)
}
async function setTheme(theme) {
await read(`(()=>{
localStorage.setItem('ycode:theme',${JSON.stringify(theme)});
window.dispatchEvent(new StorageEvent('storage',{key:'ycode:theme',newValue:${JSON.stringify(theme)}}));
return true
})()`)
await wait(`document.documentElement.dataset.theme===${JSON.stringify(theme)}`, 'main theme ' + theme)
if (settings && !settings.isDestroyed()) {
await wait(`document.documentElement.dataset.theme===${JSON.stringify(theme)}`, 'settings theme ' + theme, settings)
}
}
const cardSource = `(()=>{
const card=document.querySelector('.update-sidebar-card');
const compact=document.querySelector('.update-sidebar-card-collapsed');
if(!card&&!compact)return null;
const box=node=>{const r=node.getBoundingClientRect();return [r.x,r.y,r.width,r.height]};
const style=node=>getComputedStyle(node);
const progress=card?.querySelector('.update-sidebar-card__progress');
const primary=card?.querySelector('[data-update-action]');
return {
status:card?.getAttribute('data-status')??null,
compact:!!compact,
card:card?{box:box(card),background:style(card).backgroundColor,border:style(card).borderTopColor,
title:card.querySelector('.update-sidebar-card__title')?.textContent??'',
description:card.querySelector('.update-sidebar-card__description')?.textContent??'',
primary:primary?{text:primary.textContent.trim(),kind:primary.getAttribute('data-update-action'),disabled:primary.disabled}:null,
progress:progress?{value:progress.getAttribute('aria-valuenow'),width:progress.getBoundingClientRect().width}:null,
manual:!!card.querySelector('[data-update-manual]'),log:!!card.querySelector('[data-update-log]')}:null,
accountCardPresent:!!document.querySelector('.conversation-list-footer .update-sidebar-card-host')
}
})()`
const accountSource = `(()=>{
const row=document.querySelector('.ycode-account-menu [data-update-action]');
if(!row)return null;
return {status:row.getAttribute('data-update-status'),kind:row.getAttribute('data-update-action'),
label:row.querySelector('.user-menu-item-label')?.textContent??'',
value:row.querySelector('.user-menu-item-value')?.textContent??'',
ariaDisabled:row.getAttribute('aria-disabled')}
})()`
const aboutSource = `(()=>{
const row=document.querySelector('#settings-about-version');
const button=row?.querySelector('[data-update-action]');
const feedback=document.querySelector('.update-ui-feedback');
if(!row||!button)return null;
return {status:button.getAttribute('data-update-status'),kind:button.getAttribute('data-update-action'),
label:button.textContent.trim(),disabled:button.disabled,feedback:feedback?.textContent.replace(/\\s+/g,' ').trim()??'',
currentVersion:row.querySelector('.help-feedback-panel__card-value')?.textContent??''}
})()`
;(async () => {
await import(pathToFileURL(path.resolve('out/main/index.mjs')).href)
await app.whenReady()
for (let index = 0; index < 240; index += 1) {
main = BrowserWindow.getAllWindows().find(win => win.getTitle() === 'YCode')
if (main && !main.webContents.isLoading()) break
await pause(25)
}
assert.ok(main, 'main window')
observe(main)
main.setSize(1336, 902)
main.showInactive()
await wait('!!window.ycode&&!!document.querySelector(".conversation-list")', 'main renderer')
updateState.currentVersion = packageVersion
broadcastUpdateState()
await pause(300)
await step('ready-state')
setUpdateState({
status: 'ready',
version: '0.0.3',
notes: '改进更新卡片、失败重试与安装确认。',
progress: undefined,
error: undefined,
dismissedVersion: undefined
}, true)
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'ready update card')
snapshots.ready = await read(cardSource)
check('ready update card is mounted permanently above the account trigger',
[snapshots.ready.status, snapshots.ready.accountCardPresent, snapshots.ready.compact], ['ready', true, false])
check('ready card offers restart and shows the target version',
[snapshots.ready.card.primary.text, /v0\.0\.3/.test(snapshots.ready.card.title)], ['重启升级', true])
await step('account-sync-ready')
await click('.user-menu-trigger--workbuddy')
await wait('!!document.querySelector(".ycode-account-menu")', 'account menu')
snapshots.accountReady = await read(accountSource)
check('account menu shares the ready state and remains actionable',
[snapshots.accountReady.status, snapshots.accountReady.kind, snapshots.accountReady.label, snapshots.accountReady.ariaDisabled],
['ready', 'install', '重启升级', 'false'])
await shot('updates-main-light-ready')
await click('.ycode-account-menu [data-update-action="install"]')
check('account menu install action reaches the updates IPC bridge', actionCounts.install, 1)
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='installing'`, 'account installing state')
await read(`document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`)
await pause(100)
await shot('updates-main-light-ready-card')
await step('dismiss-and-reveal')
setUpdateState({
status: 'ready',
version: '0.0.3',
progress: undefined,
error: undefined,
dismissedVersion: undefined
})
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"] [data-update-dismiss]')`, 'ready card dismiss')
await click('[data-update-dismiss]')
await wait('!!document.querySelector(".update-sidebar-card-collapsed")', 'collapsed update entry')
check('closing the card dismisses this version and keeps a compact update entry',
[actionCounts.dismiss, await read(`!document.querySelector('.update-sidebar-card')`)], [1, true])
await click('[data-update-reveal]')
await wait('!!document.querySelector(".update-sidebar-card")', 'revealed collapsed update card')
check('the compact update entry can reveal the dismissed version card again',
await read(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`), true)
await step('error-and-manual-links')
setUpdateState({
status: 'error',
version: '0.0.4',
progress: undefined,
error: '模拟更新检查失败',
dismissedVersion: undefined
})
await wait(`!!document.querySelector('.update-sidebar-card[data-status="error"]')`, 'error update card')
snapshots.error = await read(cardSource)
check('error state exposes retry, manual download and update log',
[snapshots.error.card.primary.text, snapshots.error.card.manual, snapshots.error.card.log], ['重试', true, true])
await click('[data-update-manual]')
await click('[data-update-log]')
check('manual download and update log actions reach the updates IPC bridge',
[actionCounts.downloadPage, actionCounts.log], [1, 1])
await click('.update-sidebar-card [data-update-action="check"]')
await wait(`!!document.querySelector('.update-sidebar-card')===false`, 'checking hides the non-actionable card')
check('retry invokes the real check IPC action', actionCounts.check, 1)
setUpdateState({ status: 'ready', version: '0.0.4', error: undefined })
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'ready after retry')
await step('settings-ready')
await openSettings('helpFeedback')
observe(settings)
snapshots.aboutReady = await read(aboutSource, settings)
check('settings About shows the shared ready state',
[snapshots.aboutReady.status, snapshots.aboutReady.kind, snapshots.aboutReady.label, /新版本 v0\.0\.4 已就绪/.test(snapshots.aboutReady.feedback)],
['ready', 'install', '重启升级', true])
await shot('updates-settings-light-ready', settings)
await step('settings-download-sync')
setUpdateState({
status: 'available',
version: '0.0.5',
progress: undefined,
error: undefined,
dismissedVersion: undefined
})
await wait(`document.querySelector('#settings-about-version [data-update-status]')?.getAttribute('data-update-status')==='available'`, 'settings available state', settings)
await wait(`!!document.querySelector('.update-sidebar-card[data-status="available"]')`, 'main available card')
await click('#settings-about-version [data-update-action="download"]', settings)
await wait(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.includes('下载中 37%')`, 'settings download progress', settings)
await wait(`document.querySelector('.update-sidebar-card[data-status="downloading"]')?.querySelector('.update-sidebar-card__progress')?.getAttribute('aria-valuenow')==='37'`, 'main download progress')
snapshots.downloading = {
main: await read(cardSource),
settings: await read(aboutSource, settings)
}
check('download initiated in settings synchronizes to the sidebar card',
[actionCounts.download, snapshots.downloading.main.status, snapshots.downloading.main.card.progress.value, snapshots.downloading.settings.label],
[1, 'downloading', '37', '下载中 37%'])
setUpdateState({ status: 'ready', version: '0.0.5', progress: 100 })
await wait(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.trim()==='重启升级'`, 'settings ready after download', settings)
await wait(`!!document.querySelector('.update-sidebar-card[data-status="ready"]')`, 'main ready after download')
check('download completion synchronizes settings and sidebar', await Promise.all([
read(`document.querySelector('#settings-about-version [data-update-action]')?.textContent.trim()`, settings),
read(`document.querySelector('.update-sidebar-card__primary')?.textContent.trim()`)
]), ['重启升级', '重启升级'])
await step('dark')
await setTheme('dark')
snapshots.dark = {
main: await read(cardSource),
settings: await read(aboutSource, settings)
}
check('update card remains rendered with shared dark theme text and surface',
[snapshots.dark.main.status, snapshots.dark.main.card.background !== 'rgba(0, 0, 0, 0)'], ['ready', true])
await shot('updates-main-dark')
await shot('updates-settings-dark', settings)
await step('narrow-light')
await setTheme('light')
main.setSize(940, 800)
await pause(300)
snapshots.narrow = await read(`(()=>{
const card=document.querySelector('.update-sidebar-card');
const sidebar=document.querySelector('.conversation-list');
if(!card||!sidebar)return null;
const c=card.getBoundingClientRect(),s=sidebar.getBoundingClientRect();
return {viewport:innerWidth,card:[c.x,c.y,c.width,c.height],sidebar:[s.x,s.y,s.width,s.height],
horizontalOverflow:document.documentElement.scrollWidth>innerWidth,
title:card.querySelector('.update-sidebar-card__title')?.textContent??''}
})()`)
check('narrow window keeps the update card inside the sidebar without horizontal overflow',
[snapshots.narrow.horizontalOverflow, snapshots.narrow.card[2] <= snapshots.narrow.sidebar[2] + 1],
[false, true])
await shot('updates-main-narrow')
await step('up-to-date')
setUpdateState({
status: 'up-to-date',
version: undefined,
progress: undefined,
error: undefined,
dismissedVersion: undefined
}, true)
await wait('!document.querySelector(".update-sidebar-card")', 'up-to-date hides sidebar card')
await wait(`document.querySelector('#settings-about-version [data-update-status]')?.getAttribute('data-update-status')==='up-to-date'`, 'settings up-to-date', settings)
await click('.user-menu-trigger--workbuddy')
await wait('!!document.querySelector(".ycode-account-menu")', 'account menu up-to-date')
snapshots.upToDate = {
mainCard: await read(`!document.querySelector('.update-sidebar-card')`),
account: await read(accountSource),
settings: await read(aboutSource, settings)
}
check('up-to-date state hides the sidebar card and synchronizes account/settings',
[snapshots.upToDate.mainCard, snapshots.upToDate.account.status, snapshots.upToDate.account.value, /已是最新版本/.test(snapshots.upToDate.settings.feedback)],
[true, 'up-to-date', '已是最新', true])
await step('unsupported')
setUpdateState({
status: 'checking',
version: undefined,
progress: undefined,
error: undefined,
dismissedVersion: undefined,
unfinishedVersion: undefined
}, true)
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='checking'`, 'background check clears explicit intent')
setUpdateState({
status: 'unsupported',
version: undefined,
progress: undefined,
error: '当前平台不支持自动更新',
dismissedVersion: undefined,
unfinishedVersion: undefined
}, true)
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='unsupported'`, 'unsupported account row')
snapshots.unsupported = {
main: await read(cardSource),
account: await read(accountSource),
settings: await read(aboutSource, settings)
}
check('background unsupported state stays out of the sidebar while account/settings remain honest',
[snapshots.unsupported.main, snapshots.unsupported.account.label, snapshots.unsupported.account.value,
snapshots.unsupported.account.kind, /当前平台不支持自动更新/.test(snapshots.unsupported.settings.feedback)],
[null, '手动下载', '需手动下载', 'manual', true])
await step('background-error-quiet')
setUpdateState({
status: 'error',
version: undefined,
progress: undefined,
error: '后台检查失败',
dismissedVersion: undefined,
unfinishedVersion: undefined
}, true)
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='error'`, 'quiet background error')
await pause(150)
check('background error with no released version does not open a sidebar card',
[await read(`!document.querySelector('.update-sidebar-card')`), (await read(accountSource)).label],
[true, '重试'])
await click('.ycode-account-menu [data-update-action="check"]')
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='checking'`, 'explicit retry checking')
setUpdateState({ status: 'error', error: '显式检查失败' }, true)
await wait(`!!document.querySelector('.update-sidebar-card[data-status="error"]')`, 'explicit error card')
check('explicit retry failure is allowed to surface in the sidebar',
[actionCounts.check, await read(`document.querySelector('.update-sidebar-card__description')?.textContent`)],
[2, '显式检查失败'])
await step('unfinished-version')
setUpdateState({
status: 'up-to-date',
version: undefined,
progress: undefined,
error: undefined,
dismissedVersion: undefined,
unfinishedVersion: '0.0.6'
}, true)
await wait(`!!document.querySelector('.update-sidebar-card[data-unfinished="true"]')`, 'unfinished update card')
await wait(`document.querySelector('.ycode-account-menu [data-update-status]')?.getAttribute('data-update-status')==='up-to-date'`, 'unfinished account row')
snapshots.unfinished = {
main: await read(cardSource),
account: await read(accountSource),
settings: await read(aboutSource, settings)
}
check('unfinishedVersion survives an otherwise latest state as an actionable warning',
[snapshots.unfinished.main.card.title, snapshots.unfinished.main.card.primary.text,
snapshots.unfinished.account.label, snapshots.unfinished.account.value,
/上次升级至 v0\.0\.6 未完成/.test(snapshots.unfinished.settings.feedback)],
['上次升级未完成', '重试更新', '重试更新', '上次升级未完成', true])
await click('[data-update-log]')
check('unfinished update warning opens the update log', actionCounts.log, 2)
await step('errors')
check('no renderer error logs', rendererErrors, [])
const report = {
result: checks.every(item => item.pass) ? 'PASS' : 'FAIL',
scope: 'Real isolated YCode main/preload/renderer and settings window at 1336x902, 940x800 narrow, 900x680 settings; deterministic updates IPC injection; ready/downloading/error/up-to-date/unsupported/unfinished states, retry, dismissal, manual/log links, cross-window synchronization, light/dark screenshots.',
limitations: [
'This verifies renderer interaction and IPC call shape, not a real package download, signature verification, NSIS install, or restart.',
'The harness intercepts updates:install before the production native confirmation dialog, so confirmation copy and dialog behavior remain a main-process acceptance item.',
'Static REA evidence does not include final visual measurements for every WorkBuddy update-card state; screenshots are visual review artifacts, not pixel-parity claims.'
],
injectedUpdateChannels: [
'updates:get-state',
'updates:check',
'updates:download',
'updates:install',
'updates:dismiss',
'updates:open-download-page',
'updates:open-log'
],
absorbedProductionUpdateHandlers: absorbedUpdateHandlers,
actionCounts,
snapshots,
checks,
rendererErrors
}
await fs.writeFile(path.join(directory, 'report.json'), JSON.stringify(report, null, 2))
console.log('UPDATES_UI_' + report.result + ' ' + directory)
for (const failure of checks.filter(item => !item.pass)) {
console.log('UPDATES_UI_FAILURE ' + JSON.stringify(failure))
}
app.exit(report.result === 'PASS' ? 0 : 1)
})().catch(async error => {
await fs.writeFile(path.join(directory, 'failure.json'), JSON.stringify({
phase,
error: String(error),
stack: error.stack,
actionCounts,
checks,
snapshots,
rendererErrors
}, null, 2))
console.error(error)
app.exit(1)
})
}
+215 -14
View File
@@ -15,13 +15,14 @@ import {
} from '@anthropic-ai/claude-agent-sdk'
import { z } from 'zod'
import type { AgentMode, PermissionMode } from '@shared/conversation'
import type { ChatEvent, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types'
import type { ChatEvent, ContextUsageBreakdown, MessagePart, TokenUsage, ToolCallRecord } from '@shared/types'
import type { ResolvedModel } from '@shared/models'
import type { ThinkingLevel } from '@shared/workspaces'
import type { InteractionBroker } from '../../interaction-broker'
import { browserTool } from '../tools/browser'
import { claudeAnthropicBaseUrl } from './claude-endpoint'
import { planFromExitInput, questionsFromInput } from './claude-tool-input'
import { createClaudeCapabilityRuntime } from '../../capabilities/claude-capabilities'
const READ_ONLY_TOOLS = new Set([
'Read',
@@ -75,6 +76,9 @@ const DISALLOWED_TOOLS = [
]
interface ClaudeTurnRequest {
command?: 'compact'
skillIds?: string[]
mcpServerIds?: string[]
runId: string
userMessageId: string
prompt: string
@@ -83,6 +87,20 @@ interface ClaudeTurnRequest {
interactions: InteractionBroker
signal: AbortSignal
ownerWebContentsId?: number
onSegmentComplete?(result: ClaudeTurnResult): Promise<void>
onSegmentStart?(inputs: ClaudeAppendInput[]): Promise<void>
}
export interface ClaudeAppendInput {
userMessageId: string
prompt: string
images?: ClaudeTurnRequest['images']
skillIds?: string[]
mcpServerIds?: string[]
}
function appendError(message: string, submitted: boolean): Error & { submitted: boolean } {
return Object.assign(new Error(message), { submitted })
}
export interface ClaudeTurnResult {
@@ -118,6 +136,14 @@ interface ActiveTurn {
reasoning: Map<number, string>
lastEntryId?: string
planEmitted: boolean
streamUsage?: TokenUsage
nativeUsage?: Record<string, unknown>
compactBoundary?: boolean
foreignOutput?: boolean
groupIds: string[]
awaitingGroup: boolean
appends: Map<string, { input: ClaudeAppendInput; resolve(): void; reject(error: Error): void }>
segmentReady?: { promise: Promise<void>; resolve(): void }
}
class AsyncMessageQueue<T> implements AsyncIterable<T> {
@@ -294,12 +320,18 @@ function tokenUsage(value: unknown): TokenUsage | undefined {
}
export class ClaudeKernel {
private readonly capabilities: ReturnType<typeof createClaudeCapabilityRuntime>
private input: AsyncMessageQueue<SDKUserMessage> | null = null
private processAbort: AbortController | null = null
private instance: Query | null = null
private consuming: Promise<void> | null = null
private active: ActiveTurn | null = null
private starting = false
private draining: Promise<void> | null = null
private contextQuery: ReturnType<Query['getContextUsage']> | null = null
private lastRequestId?: string
private lastGroupKey = ''
private contextQueryGroup = ''
private closed = false
private sessionEstablished = false
private currentSessionId: string
@@ -307,6 +339,9 @@ export class ClaudeKernel {
private agentMode: AgentMode
constructor(private readonly options: ClaudeKernelOptions) {
this.capabilities = createClaudeCapabilityRuntime({ cwd: options.workspaceRoot, requestSignal: () => this.active?.request.signal, beforeCall: async () => {
if (!await this.waitForSegment()) throw new Error('当前没有可调用能力的活动回合')
} })
this.currentSessionId = options.resumeSessionId ?? options.sessionId
this.permissionMode = options.permissionMode
this.agentMode = options.agentMode
@@ -361,7 +396,7 @@ export class ClaudeKernel {
PreToolUse: [{
matcher: 'ExitPlanMode',
hooks: [async (input) => {
const active = this.active
const active = await this.waitForSegment()
if (!active) {
return {
hookSpecificOutput: {
@@ -407,6 +442,7 @@ export class ClaudeKernel {
snapshot: true
},
mcpServers: {
ycode_capabilities: this.capabilities.server,
ycode: createSdkMcpServer({
name: 'ycode',
tools: [
@@ -471,14 +507,16 @@ export class ClaudeKernel {
private async consume(instance: Query): Promise<void> {
try {
for await (const message of instance) this.handleMessage(message)
for await (const message of instance) await this.handleMessage(message)
} catch (error) {
this.failActive(error instanceof Error ? error : new Error(String(error)))
if (!this.closed) console.error('Claude Code process stopped', error)
} finally {
this.failActive(new Error('Claude Code 进程已退出'))
if (this.instance === instance) {
instance.close()
this.instance = null
this.contextQuery = null
this.input = null
this.processAbort = null
}
@@ -486,7 +524,7 @@ export class ClaudeKernel {
}
private canUseTool: CanUseTool = async (toolName, input, options): Promise<PermissionResult | null> => {
const active = this.active
const active = await this.waitForSegment()
if (!active) return { behavior: 'deny', message: '当前没有正在运行的 YCode 回合。' }
if (options.signal.aborted || active.request.signal.aborted) return { behavior: 'deny', message: '操作已取消。' }
try {
@@ -558,7 +596,7 @@ export class ClaudeKernel {
}
}
private handleMessage(message: SDKMessage): void {
private async handleMessage(message: SDKMessage): Promise<void> {
const value = message as unknown as Record<string, unknown>
if (typeof value.session_id === 'string' && value.session_id !== this.currentSessionId) {
this.rememberSessionId(value.session_id)
@@ -569,6 +607,45 @@ export class ClaudeKernel {
if (sessionId) this.rememberSessionId(sessionId)
return
}
if (value.type === 'system' && value.subtype === 'compact_boundary' && this.active?.request.command === 'compact') {
this.active.compactBoundary = true
return
}
const activeOwner = this.active
if (activeOwner?.request.signal.aborted && ['stream_event', 'assistant', 'result'].includes(String(value.type))) return
if (activeOwner && ['stream_event', 'assistant', 'result'].includes(String(value.type)) && !value.parent_tool_use_id) {
const ids = Array.isArray(value.user_message_uuids) ? value.user_message_uuids :
typeof value.user_message_uuid === 'string' ? [value.user_message_uuid] : undefined
if (activeOwner.awaitingGroup && ids?.length && ids.every(id => typeof id === 'string' && activeOwner.appends.has(id))) {
const inputs = (ids as string[]).map(id => activeOwner.appends.get(id)!.input)
await activeOwner.request.onSegmentStart!(inputs)
if (this.active !== activeOwner || activeOwner.settled || activeOwner.request.signal.aborted) return
activeOwner.groupIds = ids as string[]
this.lastGroupKey = activeOwner.groupIds.join('|')
activeOwner.request.skillIds = [...new Set([...(activeOwner.request.skillIds ?? []), ...inputs.flatMap(input => input.skillIds ?? [])])]
activeOwner.request.mcpServerIds = [...new Set([...(activeOwner.request.mcpServerIds ?? []), ...inputs.flatMap(input => input.mcpServerIds ?? [])])]
this.capabilities.setRequestSelection({ explicitSkillIds: activeOwner.request.skillIds, selectedMcpServerIds: activeOwner.request.mcpServerIds })
activeOwner.awaitingGroup = false
activeOwner.content = ''
activeOwner.tools = new Map()
activeOwner.toolOrder = []
activeOwner.reasoning = new Map()
activeOwner.streamUsage = undefined
activeOwner.nativeUsage = undefined
activeOwner.lastEntryId = undefined
activeOwner.planEmitted = false
for (const input of inputs) {
const pending = activeOwner.appends.get(input.userMessageId)!
activeOwner.appends.delete(input.userMessageId)
pending.resolve()
}
activeOwner.segmentReady?.resolve()
activeOwner.segmentReady = undefined
}
if (ids?.length) activeOwner.foreignOutput = !ids.some(id => activeOwner.groupIds.includes(String(id)))
if (activeOwner.awaitingGroup) return
if (activeOwner.foreignOutput) return
}
if (value.type === 'stream_event') {
const event = value.event as Record<string, unknown> | undefined
@@ -578,6 +655,19 @@ export class ClaudeKernel {
const index = typeof event.index === 'number' ? event.index : 0
if (event.type === 'message_start') {
active.assistantSequence++
const message = event.message as Record<string, unknown> | undefined
active.nativeUsage = message?.usage && typeof message.usage === 'object' ? message.usage as Record<string, unknown> : undefined
active.streamUsage = tokenUsage(active.nativeUsage)
if (active.streamUsage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage: active.streamUsage })
return
}
if (event.type === 'message_delta') {
const usage = event.usage as Record<string, unknown> | undefined
if (usage && typeof usage === 'object') {
active.nativeUsage = { ...active.nativeUsage, ...usage }
active.streamUsage = tokenUsage(active.nativeUsage)
if (active.streamUsage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage: active.streamUsage })
}
return
}
if (event.type === 'content_block_delta') {
@@ -679,10 +769,13 @@ export class ClaudeKernel {
if (value.type === 'result') {
const active = this.active
if (!active) return
const usage = tokenUsage(value.usage)
if (usage) active.request.emit({ runId: active.request.runId, type: 'usage', usage })
// A result aggregates multiple API calls; it is not the last request's context size.
const usage = active.streamUsage
if (usage && !active.request.command) active.request.emit({ runId: active.request.runId, type: 'usage', usage })
const failed = value.is_error === true || value.subtype !== 'success'
if (failed) {
if (active.request.command === 'compact' && !failed && !active.compactBoundary) {
active.reject(new Error('内核未确认原生压缩完成,不能将普通回复视为压缩成功'))
} else if (failed) {
const errors = Array.isArray(value.errors) ? value.errors.filter((item): item is string => typeof item === 'string') : []
const message = errors.length
? errors.join('\n')
@@ -691,11 +784,25 @@ export class ClaudeKernel {
: `Claude Code 回合失败 (${String(value.subtype ?? 'unknown')})`
active.reject(new Error(message))
} else {
if (active.request.command === 'compact') {
active.content = '上下文压缩完成'
active.request.emit({ runId: active.request.runId, type: 'text', delta: active.content })
}
if (!active.content && typeof value.result === 'string') {
active.content = value.result
active.request.emit({ runId: active.request.runId, type: 'text', delta: value.result })
}
active.resolve({ content: active.content, tools: [...active.toolOrder], status: 'done', entryId: active.lastEntryId })
const result: ClaudeTurnResult = { content: active.content, tools: [...active.toolOrder], status: 'done', entryId: active.lastEntryId }
if (active.appends.size) {
active.awaitingGroup = true
let ready!: () => void
const promise = new Promise<void>(resolve => { ready = resolve })
active.segmentReady = { promise, resolve: ready }
await active.request.onSegmentComplete!(result)
if (this.active !== active || active.settled) return
return
}
active.resolve(result)
}
this.finishActive(active)
}
@@ -712,21 +819,33 @@ export class ClaudeKernel {
private finishActive(active: ActiveTurn): void {
if (active.settled) return
active.settled = true
active.segmentReady?.resolve()
for (const pending of active.appends.values()) pending.reject(appendError('运行已结束,追加消息交付未确认', true))
active.appends.clear()
if (this.active === active) this.active = null
}
private async waitForSegment(): Promise<ActiveTurn | undefined> {
const active = this.active
if (!active) return undefined
if (active.awaitingGroup) await active.segmentReady?.promise
return this.active === active && !active.settled && !active.request.signal.aborted ? active : undefined
}
private failActive(error: Error): void {
const active = this.active
if (!active || active.settled) return
active.reject(error)
active.reject(active.request.signal.aborted ? abortError() : error)
this.finishActive(active)
}
async runTurn(request: ClaudeTurnRequest): Promise<ClaudeTurnResult> {
await this.draining
if (this.closed) throw new Error('Claude 内核已释放')
if (this.active || this.starting) throw new Error('该会话正在运行')
this.starting = true
try {
this.capabilities.setRequestSelection({ explicitSkillIds: request.skillIds, selectedMcpServerIds: request.mcpServerIds })
await this.ensureStarted()
const input = this.input
if (!input) throw new Error('Claude Code 输入流未就绪')
@@ -742,13 +861,23 @@ export class ClaudeKernel {
settled: false,
assistantSequence: 0,
reasoning: new Map(),
planEmitted: false
planEmitted: false,
groupIds: [request.userMessageId], awaitingGroup: false, appends: new Map()
}
const onAbort = (): void => {
if (active.settled) return
void this.instance?.interrupt().catch(() => undefined)
active.reject(abortError())
this.finishActive(active)
active.segmentReady?.resolve()
// Retire the producer before allowing a new turn. A late result from an
// interrupted producer must never settle the next turn's promise.
const consuming = this.consuming
this.processAbort?.abort()
this.instance?.close()
const draining = (consuming ?? Promise.resolve()).catch(() => undefined)
this.draining = draining
void draining.finally(() => { if (this.draining === draining) this.draining = null })
void draining.finally(() => {
if (!active.settled) { active.reject(abortError()); this.finishActive(active) }
})
}
request.signal.addEventListener('abort', onAbort, { once: true })
const originalResolve = active.resolve
@@ -762,9 +891,12 @@ export class ClaudeKernel {
originalReject(error)
}
this.active = active
this.lastRequestId = request.runId
this.lastGroupKey = request.userMessageId
try {
input.push({
type: 'user',
...(request.command ? {} : { client_composed: true }),
message: {
role: 'user',
content: [
@@ -793,12 +925,80 @@ export class ClaudeKernel {
}
}
/** Resolve only after an identity-stamped native response and host history commit. */
appendInput(input: ClaudeAppendInput): Promise<void> {
const active = this.active
if (!active || active.settled || active.request.command || !this.input || this.draining || this.closed ||
!active.request.onSegmentStart || !active.request.onSegmentComplete) {
return Promise.reject(appendError('当前运行尚不能接收引导消息,请稍后重试', false))
}
if (active.request.signal.aborted || active.appends.has(input.userMessageId) || active.groupIds.includes(input.userMessageId)) {
return Promise.reject(appendError('追加消息已提交或当前运行已取消', false))
}
return new Promise<void>((resolve, reject) => {
active.appends.set(input.userMessageId, { input, resolve, reject })
try {
this.input!.push({ type: 'user', client_composed: true, parent_tool_use_id: null,
uuid: input.userMessageId as SDKUserMessage['uuid'], session_id: this.currentSessionId,
message: { role: 'user', content: [
{ type: 'text', text: input.prompt },
...(input.images ?? []).map(image => ({ type: 'image' as const, source: { type: 'base64' as const,
media_type: image.mimeType as 'image/png', data: image.data } }))
] } })
} catch (error) {
active.appends.delete(input.userMessageId)
reject(appendError(error instanceof Error ? error.message : String(error), false))
}
})
}
async setPolicy(permissionMode: PermissionMode, agentMode: AgentMode): Promise<void> {
await this.draining
this.permissionMode = permissionMode
this.agentMode = agentMode
if (this.instance) await this.instance.setPermissionMode(permissionModeFor(permissionMode, agentMode))
}
/** Read only: never start a kernel or request per-category token-count calls. */
async getContextSummary(): Promise<Awaited<ReturnType<Query['getContextUsage']>> | undefined> {
if (!this.instance || this.draining || this.closed) return undefined
const query = this.contextQuery ?? this.instance.getContextUsage({ detail: 'summary' })
if (!this.contextQuery) {
this.contextQuery = query
this.contextQueryGroup = this.lastGroupKey
void query.finally(() => { if (this.contextQuery === query) this.contextQuery = null }).catch(() => {})
}
let timer: ReturnType<typeof setTimeout> | undefined
try {
return await Promise.race([query, new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('内核上下文统计查询超时')), 5000)
timer.unref()
})])
} finally { if (timer) clearTimeout(timer) }
}
async getContextBreakdown(): Promise<ContextUsageBreakdown | undefined> {
const requestId = this.lastRequestId
const groupKey = this.lastGroupKey
if (this.contextQuery && this.contextQueryGroup !== groupKey) return undefined
const instance = this.instance
const sampledAt = Date.now()
const result = await this.getContextSummary()
if (!result || !requestId || requestId !== this.lastRequestId || groupKey !== this.lastGroupKey || instance !== this.instance) return undefined
const counts: ContextUsageBreakdown['counts'] = { system: 0, tools: 0, conversation: 0, mcp: 0, skills: 0, other: 0 }
for (const category of result.categories) {
if (category.kind !== 'used' || !Number.isFinite(category.tokens) || category.tokens < 0) continue
const id = category.id
const key = id === 'system_prompt' || id === 'memory_files' ? 'system' : id === 'system_tools' ? 'tools' :
id === 'messages' ? 'conversation' : id === 'mcp_tools' || id === 'mcp_instructions' ? 'mcp' : id === 'skills' ? 'skills' : 'other'
counts[key] += category.tokens
}
const contextWindow = this.options.model.profile.contextWindowTokens ?? this.options.model.profile.maxInputTokens
return { requestId, sequence: sampledAt, sampledAt, modelId: this.options.model.profile.id,
contextWindow, method: 'native-summary', scope: 'prepared-request', counts,
inputTokens: Object.values(counts).reduce((sum, value) => sum + value, 0), partial: true }
}
async rewindFiles(userMessageId: string): Promise<void> {
if (!this.instance) return
const result = await this.instance.rewindFiles(userMessageId, { dryRun: false })
@@ -839,6 +1039,7 @@ function mustAskInFullAccess(options: Parameters<CanUseTool>[2]): boolean {
function isReadOnlyCall(toolName: string, input: Record<string, unknown>): boolean {
if (READ_ONLY_TOOLS.has(toolName)) return true
if (['mcp__ycode_capabilities__list_capabilities', 'mcp__ycode_capabilities__load_skill', 'mcp__ycode_capabilities__read_skill_resource'].includes(toolName)) return true
if (toolName !== 'mcp__ycode__browser') return false
return ['open', 'navigate', 'snapshot', 'wait', 'list', 'screenshot'].includes(String(input.action ?? ''))
}
+24
View File
@@ -0,0 +1,24 @@
import type { PermissionMode } from '../../shared/conversation'
export interface QueuedRequestSettingsInput {
queued: boolean
payloadPermissionMode?: PermissionMode
sessionPermissionMode?: PermissionMode
}
export interface QueuedRequestSettings {
permissionMode: PermissionMode
persistSessionPermission: boolean
}
/** Direct sends may seed session policy; queued delivery always follows current session policy. */
export function resolveQueuedRequestSettings(input: QueuedRequestSettingsInput): QueuedRequestSettings {
const permissionMode = input.queued
? input.sessionPermissionMode ?? 'default-sandbox'
: input.payloadPermissionMode ?? input.sessionPermissionMode ?? 'default-sandbox'
if (permissionMode !== 'default-sandbox' && permissionMode !== 'full-access') throw new Error('权限模式无效')
return {
permissionMode,
persistSessionPermission: !input.queued && input.sessionPermissionMode !== permissionMode
}
}
+17
View File
@@ -0,0 +1,17 @@
import type { SessionContext } from '../../shared/workspaces'
import { thinkingLevel } from './mode-policy'
/** During a run only future-request settings may change, never active policy. */
export function nextRequestSettings(patch: SessionContext): SessionContext {
if (!patch || typeof patch !== 'object' || Array.isArray(patch) ||
Object.keys(patch).some(key => key !== 'modelId' && key !== 'thinkingLevel')) {
throw new Error('运行期间只能修改下次发送的模型和思考强度')
}
const result: SessionContext = {}
if (patch.modelId !== undefined) {
if (typeof patch.modelId !== 'string' || !patch.modelId.trim()) throw new Error('模型标识无效')
result.modelId = patch.modelId
}
if (patch.thinkingLevel !== undefined) result.thinkingLevel = thinkingLevel(patch.thinkingLevel)
return result
}
@@ -0,0 +1,244 @@
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk'
import { z } from 'zod'
import type { SkillRecord } from '@shared/capabilities'
import {
discoverMcpCapabilities,
executeMcpTool,
type McpCapabilityDescriptor
} from './mcp'
import {
buildSkillContext,
listSkills,
loadSkillContext,
readSkillResourceForInvocation
} from './skills'
export interface ClaudeCapabilityPorts {
mcp: {
discover(options?: { serverId?: string; query?: string }): Promise<McpCapabilityDescriptor[]>
execute(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal):
Promise<{ ok: boolean; content: string; data?: unknown }>
}
skills: {
list(cwd: string): Promise<SkillRecord[]>
loadAutomatic(id: string, cwd: string): Promise<string>
loadExplicit(ids: string[], cwd: string): Promise<string>
readResource(id: string, resource: string, cwd: string, explicit: boolean): Promise<string>
}
}
export interface ClaudeCapabilityOptions {
beforeCall?(): Promise<void>
requestSignal?(): AbortSignal | undefined
cwd: string
/** Explicit references belong to this request only and never alter the global enabled flag. */
explicitSkillIds?: readonly string[]
selectedMcpServerIds?: readonly string[]
}
export interface ClaudeCapabilityCatalog {
mcp: Array<Omit<McpCapabilityDescriptor, 'tools'> & {
selected: boolean
tools: Array<{ name: string; description: string; inputSchema?: Record<string, unknown> }>
}>
skills: Array<Pick<SkillRecord, 'id' | 'name' | 'description' | 'userInvocable' | 'disableModelInvocation'> & {
selected: boolean
}>
}
const MAX_QUERY = 256
const MAX_LISTED_MCP_TOOLS = 200
const MAX_DESCRIPTION = 600
const defaultPorts: ClaudeCapabilityPorts = {
mcp: { discover: discoverMcpCapabilities, execute: executeMcpTool },
skills: {
list: listSkills,
loadAutomatic: loadSkillContext,
loadExplicit: buildSkillContext,
readResource: readSkillResourceForInvocation
}
}
function textResult(text: string, isError = false) {
return { content: [{ type: 'text' as const, text }], isError }
}
function message(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function normalizedQuery(value: string | undefined): string {
const query = value?.trim() ?? ''
if (query.length > MAX_QUERY) throw new Error('能力搜索词过长')
return query.toLocaleLowerCase()
}
function matches(query: string, ...values: string[]): boolean {
return !query || values.some(value => value.toLocaleLowerCase().includes(query))
}
export function createClaudeCapabilityRuntime(
options: ClaudeCapabilityOptions,
override: Partial<ClaudeCapabilityPorts> = {}
) {
const ports: ClaudeCapabilityPorts = {
mcp: override.mcp ?? defaultPorts.mcp,
skills: { ...defaultPorts.skills, ...override.skills }
}
let explicitSkillIds = new Set(options.explicitSkillIds ?? [])
let selectedMcpServerIds = new Set(options.selectedMcpServerIds ?? [])
/** The persistent kernel is sequential; switch its request-scoped references before each turn. */
function setRequestSelection(next: Pick<ClaudeCapabilityOptions, 'explicitSkillIds' | 'selectedMcpServerIds'>): void {
explicitSkillIds = new Set(next.explicitSkillIds ?? [])
selectedMcpServerIds = new Set(next.selectedMcpServerIds ?? [])
}
async function findSkill(id: string): Promise<SkillRecord> {
const skill = (await ports.skills.list(options.cwd)).find(item => item.id === id)
if (!skill) throw new Error('技能不存在或不属于当前空间')
if (skill.error) throw new Error(skill.error)
if (!skill.enabled) throw new Error('该技能已禁用')
if (explicitSkillIds.has(id)) {
if (!skill.userInvocable) throw new Error('该技能不允许用户手动调用')
} else if (skill.disableModelInvocation) {
throw new Error('该技能不允许模型自动调用')
}
return skill
}
async function list(queryValue?: string, serverId?: string): Promise<ClaudeCapabilityCatalog> {
await options.beforeCall?.()
const query = normalizedQuery(queryValue)
const serverFilter = serverId?.trim() || undefined
if (serverFilter && serverFilter.length > 256) throw new Error('MCP 服务标识过长')
const [mcp, skills] = await Promise.all([
ports.mcp.discover({ ...(serverFilter ? { serverId: serverFilter } : {}), ...(query ? { query: queryValue?.trim() } : {}) }),
ports.skills.list(options.cwd)
])
let remainingTools = MAX_LISTED_MCP_TOOLS
const mcpView = mcp
.filter(server => serverFilter ? server.id === serverFilter : true)
.map(server => {
const serverMatches = matches(query, server.name, server.reason ?? '')
const filtered = !query || serverMatches
? server.tools
: server.tools.filter(item => matches(query, item.name, item.description))
const tools = filtered.slice(0, remainingTools).map(item => ({
name: item.name,
description: item.description.slice(0, MAX_DESCRIPTION),
...(serverFilter || query && matches(query, item.name, item.description)
? item.inputSchema ? { inputSchema: item.inputSchema } : {}
: {})
}))
remainingTools -= tools.length
return {
...server,
selected: selectedMcpServerIds.has(server.id),
tools,
...(filtered.length > tools.length ? { reason: '工具列表已截断,请使用搜索词继续发现' } : {})
}
})
.filter(server => !query || server.tools.length > 0 || matches(query, server.name, server.reason ?? ''))
const skillView = skills
.filter(skill => skill.enabled && !skill.error)
.filter(skill => explicitSkillIds.has(skill.id) ? skill.userInvocable : !skill.disableModelInvocation)
.filter(skill => matches(query, skill.name, skill.description))
.map(skill => ({
id: skill.id,
name: skill.name,
description: skill.description,
userInvocable: skill.userInvocable,
disableModelInvocation: skill.disableModelInvocation,
selected: explicitSkillIds.has(skill.id)
}))
return { mcp: mcpView, skills: skillView }
}
async function loadSkill(id: string): Promise<string> {
await options.beforeCall?.()
await findSkill(id)
return explicitSkillIds.has(id)
? ports.skills.loadExplicit([id], options.cwd)
: ports.skills.loadAutomatic(id, options.cwd)
}
async function skillResource(id: string, resource: string): Promise<string> {
await options.beforeCall?.()
await findSkill(id)
return ports.skills.readResource(id, resource, options.cwd, explicitSkillIds.has(id))
}
async function explicitSkillContext(): Promise<string> {
if (!explicitSkillIds.size) return ''
for (const id of explicitSkillIds) await findSkill(id)
return ports.skills.loadExplicit([...explicitSkillIds], options.cwd)
}
async function callMcp(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal) {
await options.beforeCall?.()
if (!serverId || !toolName) throw new Error('MCP 服务和工具标识不能为空')
if (!args || typeof args !== 'object' || Array.isArray(args)) throw new Error('MCP 工具参数必须是对象')
const requestSignal = options.requestSignal?.()
return ports.mcp.execute(serverId, toolName, args, requestSignal ? AbortSignal.any([signal, requestSignal]) : signal)
}
const server = createSdkMcpServer({
name: 'ycode_capabilities',
version: '1.0.0',
// Keep only the four small bridge tools visible; supplier tools stay on demand.
alwaysLoad: true,
instructions: 'YCode capabilities are discovered on demand. List before invoking an unfamiliar MCP tool or loading a skill body. Selecting a capability is not proof that it ran.',
tools: [
tool(
'list_capabilities',
'List currently enabled MCP servers/tools and skills. Use a search query when the catalog is large. This only discovers capabilities; it does not run them.',
{
query: z.string().max(MAX_QUERY).optional(),
serverId: z.string().min(1).max(256).optional()
},
async args => {
try { return textResult(JSON.stringify(await list(args.query, args.serverId))) }
catch (error) { return textResult(message(error), true) }
}
),
tool(
'call_mcp_tool',
'Call one enabled tool from an approved, connected MCP server. The host rechecks service approval, enabled state, connection generation and tool state before invocation.',
{
serverId: z.string().min(1).max(256),
toolName: z.string().min(1).max(256),
arguments: z.record(z.string(), z.unknown())
},
async (args, extra) => {
const signal = (extra as { signal?: AbortSignal } | undefined)?.signal ?? new AbortController().signal
try {
const result = await callMcp(args.serverId, args.toolName, args.arguments, signal)
return textResult(result.content, !result.ok)
} catch (error) { return textResult(message(error), true) }
}
),
tool(
'load_skill',
'Load the selected or model-invocable SKILL.md body and its resource list on demand. Skills are not executed by this tool.',
{ id: z.string().min(1).max(256) },
async args => {
try { return textResult(await loadSkill(args.id)) }
catch (error) { return textResult(message(error), true) }
}
),
tool(
'read_skill_resource',
'Read a UTF-8 resource by path relative to its skill package root. Paths outside the package and symlinks are rejected.',
{ id: z.string().min(1).max(256), resource: z.string().min(1).max(4096) },
async args => {
try { return textResult(await skillResource(args.id, args.resource)) }
catch (error) { return textResult(message(error), true) }
}
)
]
})
return { setRequestSelection, list, loadSkill, skillResource, explicitSkillContext, callMcp, server }
}
+61
View File
@@ -241,6 +241,7 @@ export async function connectMcpServer(id: string): Promise<McpServerView> {
}
export async function disconnectMcpServer(id: string): Promise<void> { await writes; requireServer(await load(), id); await closeRuntime(id) }
export async function setMcpEnabled(id: string, enabled: boolean): Promise<void> {
if (typeof enabled !== 'boolean') throw new Error('MCP 启停状态无效')
await mutate(async current => {
const server = requireServer(current, id)
if (enabled && server.trustedHash !== server.configHash) throw new Error('请先批准当前 MCP 配置')
@@ -250,6 +251,7 @@ export async function setMcpEnabled(id: string, enabled: boolean): Promise<void>
if (enabled) await connectMcpServer(id)
}
export async function setMcpToolEnabled(id: string, toolName: string, enabled: boolean): Promise<void> {
if (typeof enabled !== 'boolean') throw new Error('MCP 工具启停状态无效')
await mutate(async current => {
const server = requireServer(current, id)
if (!server.tools.some(tool => tool.name === toolName)) throw new Error('MCP 工具不存在')
@@ -265,6 +267,65 @@ export async function initializeMcpServers(): Promise<void> {
await writes
for (const server of (await load()).servers) if (server.enabled && server.trustedHash === server.configHash) void connectMcpServer(server.id).catch(() => undefined)
}
export interface McpCapabilityDescriptor {
id: string
name: string
status: McpStatus
available: boolean
reason?: string
tools: Array<{ name: string; description: string; inputSchema?: Record<string, unknown> }>
}
export interface McpDiscoveryOptions { serverId?: string; query?: string }
/** Model discovery never connects or restarts a server; it only reports current approved state. */
export async function discoverMcpCapabilities(options: McpDiscoveryOptions = {}): Promise<McpCapabilityDescriptor[]> {
await writes
const query = options.query?.trim().toLocaleLowerCase() ?? ''
const serverId = options.serverId?.trim()
return (await load()).servers.filter(server => server.enabled && (!serverId || server.id === serverId)).map(server => {
const runtime = runtimes.get(server.id)
const approved = server.trustedHash === server.configHash
const available = approved && runtime?.status === 'connected'
const serverMatches = !query || [server.name, runtime?.error ?? ''].some(value => value.toLocaleLowerCase().includes(query))
const tools = available ? server.tools.filter(tool => {
if (!tool.enabled) return false
return serverMatches || [tool.name, tool.description].some(value => value.toLocaleLowerCase().includes(query))
}) : []
return {
id: server.id,
name: server.name,
status: runtime?.status ?? 'disconnected',
available,
...(!available ? { reason: !approved ? '请先批准当前 MCP 配置' : runtime?.error ?? 'MCP 服务尚未连接' } : {}),
// Schemas require an explicit drill-down or a tool search, never the default catalog.
tools: tools.map(tool => ({ name: tool.name, description: tool.description,
...(serverId || query && [tool.name, tool.description].some(value => value.toLocaleLowerCase().includes(query))
? { inputSchema: clone(tool.inputSchema) } : {}) }))
}
})
}
export async function executeMcpTool(serverId: string, toolName: string, args: Record<string, unknown>, signal: AbortSignal): Promise<{ ok: boolean; content: string; data?: unknown }> {
if (signal.aborted) throw abortError()
await writes
const server = (await load()).servers.find(item => item.id === serverId), runtime = runtimes.get(serverId)
if (!server) return { ok: false, content: 'MCP 服务不存在' }
if (!server.enabled) return { ok: false, content: 'MCP 服务已禁用' }
if (server.trustedHash !== server.configHash) return { ok: false, content: '请先批准当前 MCP 配置' }
if (!runtime || runtime.configHash !== server.configHash || runtime.status !== 'connected') return { ok: false, content: runtime?.error ?? 'MCP 服务尚未连接' }
if (!server.tools.some(tool => tool.name === toolName && tool.enabled)) return { ok: false, content: 'MCP 工具不存在或已禁用' }
try {
const result = await runtime.client.callTool({ name: toolName, arguments: args }, undefined,
{ signal: AbortSignal.any([signal, runtime.controller.signal]), timeout: CALL_TIMEOUT })
const data = mask(result, runtime.secretValues) as Record<string, unknown>
const content = Array.isArray(data.content)
? data.content.map(part => object(part) && part.type === 'text' && typeof part.text === 'string' ? part.text : JSON.stringify(part)).join('\n')
: JSON.stringify(data)
return { ok: !result.isError, content, data }
} catch (error) {
if (signal.aborted) throw abortError()
if (runtime.controller.signal.aborted || runtimes.get(serverId) !== runtime) return { ok: false, content: 'MCP 配置或连接已变化,请重新发起请求' }
return { ok: false, content: publicError(error, 'call') }
}
}
function mask(value: unknown, secrets: string[]): unknown {
if (typeof value === 'string') { for (const secret of secrets) if (secret) value = (value as string).split(secret).join('[已隐藏]'); return value }
if (Array.isArray(value)) return value.map(item => mask(item, secrets))
+247
View File
@@ -0,0 +1,247 @@
import { app } from 'electron'
import { promises as fs } from 'node:fs'
import path from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import type { RecommendedSkill, RecommendedSkillInstallOptions, SkillInstallProgress, SkillRecord } from '@shared/capabilities'
import { SKILL_RECOMMENDATIONS } from './skill-recommendations'
import { getSkillInstallSnapshot, importRecommendedSkillArchive } from './skills'
const MAX_BYTES = 20 * 1024 * 1024
const UUID = /^[a-f\d]{8}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{4}-[a-f\d]{12}$/i
const DIRECTORY = /^install-(\d+)-[a-f\d-]{36}$/i
const ACTIVE_PHASES = new Set(['downloading', 'validating', 'installing'])
export interface RecommendedSkillInstallerPorts {
tempRoot: string
catalog: readonly RecommendedSkill[]
snapshot: typeof getSkillInstallSnapshot
importArchive: typeof importRecommendedSkillArchive
request?: typeof fetch
timeoutMs?: number
}
/** The factory is a test boundary; IPC only exposes the fixed production catalog. */
export function createRecommendedSkillInstaller(ports: RecommendedSkillInstallerPorts) {
const root = path.resolve(ports.tempRoot)
const catalog = structuredClone([...ports.catalog])
const listeners = new Set<(progress: SkillInstallProgress) => void>()
const usedIds = new Set<string>()
let state: SkillInstallProgress | null = null
let stopping = false
let initialized = false
let lifecycle = 0
let initialization: Promise<void> | undefined
let active: { operationId: string; controller: AbortController; task: Promise<SkillRecord> } | undefined
function publish(operationId: string, recommendationId: string, phase: SkillInstallProgress['phase'],
receivedBytes: number, totalBytes?: number, error?: string): void {
state = { operationId, recommendationId, phase, receivedBytes, totalBytes, error,
updatedAt: Math.max(Date.now(), (state?.updatedAt ?? 0) + 1) }
for (const listener of listeners) {
try { listener(structuredClone(state)) } catch { /* A closed renderer cannot fail installation. */ }
}
}
async function ensureRoot(): Promise<void> {
let current = path.parse(root).root
for (const component of path.relative(current, root).split(path.sep)) {
current = path.join(current, component)
await fs.mkdir(current).catch(error => { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error })
const stat = await fs.lstat(current)
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('技能安装临时目录不能包含符号链接或文件')
}
}
async function removeOwned(directory: string): Promise<void> {
const target = path.resolve(directory)
if (path.dirname(target) !== root || !DIRECTORY.test(path.basename(target))) throw Error('技能安装临时路径越界')
const stat = await fs.lstat(target).catch(error => {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
throw error
})
if (!stat) return
if (stat.isSymbolicLink() || !stat.isDirectory()) throw Error('技能安装临时目录已被重定向')
await fs.rm(target, { recursive: true, force: true })
}
async function ensureInitialized(): Promise<void> {
if (initialized) return
if (!initialization) {
initialization = (async () => {
await ensureRoot()
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
const match = DIRECTORY.exec(entry.name)
if (!match || !entry.isDirectory() || entry.isSymbolicLink()) continue
let alive = true
try { process.kill(Number(match[1]), 0) }
catch (error) { alive = (error as NodeJS.ErrnoException).code !== 'ESRCH' }
if (!alive) await removeOwned(path.join(root, entry.name))
}
initialized = true
})()
void initialization.catch(() => { initialization = undefined })
}
await initialization
}
function downloadUrl(recommendation: RecommendedSkill): URL {
if (!/^[\w.-]+\/[\w.-]+$/.test(recommendation.repository) || !/^[a-f\d]{40}$/i.test(recommendation.commit) ||
!/^[a-f\d]{64}$/i.test(recommendation.sha256) || recommendation.packagePath.split('/').some(part => !part || part === '.' || part === '..' || /[^\w.-]/.test(part))) {
throw Error('推荐技能目录条目无效')
}
// Request the pinned file bytes through GitHub's API; raw.githubusercontent.com
// is reset by some networks even when api.github.com remains reachable.
return new URL(`https://api.github.com/repos/${recommendation.repository}/contents/${recommendation.packagePath}?ref=${recommendation.commit}`)
}
async function download(recommendation: RecommendedSkill, file: string, signal: AbortSignal,
operationId: string): Promise<number> {
const request = ports.request ?? fetch
let url = downloadUrl(recommendation)
let response: Response | undefined
for (let redirects = 0; redirects <= 3; redirects++) {
signal.throwIfAborted()
if (url.protocol !== 'https:' || !['api.github.com', 'raw.githubusercontent.com'].includes(url.hostname) || url.username || url.password || (url.port && url.port !== '443')) throw Error('推荐技能下载跳转到不允许的地址')
response = await request(url, { signal, redirect: 'manual', headers: {
'Accept-Encoding': 'identity', Accept: 'application/vnd.github.raw+json', 'X-GitHub-Api-Version': '2022-11-28'
} }).catch(error => {
if (signal.aborted) throw error
throw Error('无法连接 GitHub 下载服务,请检查网络或代理后重试', { cause: error })
})
if (![301, 302, 303, 307, 308].includes(response.status)) break
const location = response.headers.get('location')
await response.body?.cancel()
if (!location || redirects === 3) throw Error('推荐技能下载重定向过多或缺少地址')
url = new URL(location, url)
}
if (!response?.ok || !response.body) {
await response?.body?.cancel().catch(() => {})
if (response?.status === 429 || response?.status === 403 && response.headers.get('x-ratelimit-remaining') === '0') throw Error('GitHub 下载请求限额已用完,请稍后重试')
throw Error(`推荐技能下载失败(HTTP ${response?.status ?? 'unknown'})`)
}
const declared = Number(response.headers.get('content-length'))
if (declared > MAX_BYTES) { await response.body.cancel(); throw Error('推荐技能包超过 20MB') }
const handle = await fs.open(file, 'wx').catch(async error => { await response!.body!.cancel().catch(() => {}); throw error })
const reader = response.body.getReader()
const digest = createHash('sha256')
let received = 0, lastProgress = 0
try {
for (;;) {
signal.throwIfAborted()
const chunk = await reader.read()
if (chunk.done) break
received += chunk.value.byteLength
if (received > MAX_BYTES) throw Error('推荐技能包超过 20MB')
digest.update(chunk.value)
await handle.writeFile(chunk.value)
if (Date.now() - lastProgress >= 80 || received === recommendation.expectedBytes) {
publish(operationId, recommendation.id, 'downloading', received, recommendation.expectedBytes)
lastProgress = Date.now()
}
}
signal.throwIfAborted()
if (received !== recommendation.expectedBytes) throw Error('推荐技能包大小与目录不符')
if (digest.digest('hex') !== recommendation.sha256) throw Error('推荐技能包 SHA-256 校验失败')
return received
} finally {
await reader.cancel().catch(() => {})
await handle.close()
}
}
function install(id: string, options: RecommendedSkillInstallOptions): Promise<SkillRecord> {
if (stopping) return Promise.reject(Error('技能安装服务正在停止'))
const recommendation = catalog.find(item => item.id === id)
if (!recommendation) return Promise.reject(Error('推荐技能不存在'))
if (!options || !UUID.test(options.operationId) || (options.overwrite !== undefined && typeof options.overwrite !== 'boolean')) return Promise.reject(Error('技能安装操作参数无效'))
if (active) return Promise.reject(Error('已有推荐技能正在安装,请等待当前操作完成'))
if (usedIds.has(options.operationId)) return Promise.reject(Error('技能安装操作已提交,不能重复执行'))
const operationId = options.operationId
usedIds.add(operationId)
const controller = new AbortController()
let directory: string | undefined
let received = 0, timedOut = false
const task = Promise.resolve().then(async () => {
try {
await ensureInitialized()
controller.signal.throwIfAborted()
const snapshot = await ports.snapshot(recommendation.name)
const existing = snapshot.skill
const origin = existing?.origin
if (existing && !existing.error && !existing.userModified && origin?.recommendationId === recommendation.id &&
origin.repository === recommendation.repository && origin.commit === recommendation.commit &&
origin.packagePath === recommendation.packagePath && origin.archiveSha256 === recommendation.sha256 && origin.version === recommendation.version) {
controller.signal.throwIfAborted()
publish(operationId, id, 'installed', 0)
return existing
}
if (existing && !options.overwrite) throw Error('已存在同名技能,请确认替换后重试')
directory = path.join(root, `install-${process.pid}-${randomUUID()}`)
await fs.mkdir(directory)
const file = path.join(directory, 'package.zip')
publish(operationId, id, 'downloading', 0, recommendation.expectedBytes)
const timer = setTimeout(() => { timedOut = true; controller.abort() }, ports.timeoutMs ?? 120000)
timer.unref()
try { received = await download(recommendation, file, controller.signal, operationId) }
finally { clearTimeout(timer) }
controller.signal.throwIfAborted()
publish(operationId, id, 'validating', received, recommendation.expectedBytes)
const skill = await ports.importArchive(file, recommendation, options.overwrite === true, snapshot.fingerprint, () => {
controller.signal.throwIfAborted()
publish(operationId, id, 'installing', received, recommendation.expectedBytes)
})
publish(operationId, id, 'installed', received, recommendation.expectedBytes)
return skill
} catch (error) {
const message = timedOut ? '推荐技能下载超时,请重试' : controller.signal.aborted ? '推荐技能安装已取消' : error instanceof Error ? error.message : String(error)
publish(operationId, id, controller.signal.aborted && !timedOut ? 'cancelled' : 'failed', state?.operationId === operationId ? state.receivedBytes : received, recommendation.expectedBytes, message)
throw Object.assign(Error(message), { name: controller.signal.aborted && !timedOut ? 'AbortError' : 'Error' })
} finally {
if (directory) await removeOwned(directory).catch(error => console.error('技能安装临时文件清理失败', error))
}
})
active = { operationId, controller, task }
void task.finally(() => { if (active?.operationId === operationId) active = undefined }).catch(() => {})
return task
}
return {
recommendations: (): RecommendedSkill[] => structuredClone(catalog),
install,
cancel(operationId: string): { cancelled: boolean } {
if (typeof operationId !== 'string' || !UUID.test(operationId)) throw Error('技能安装操作标识无效')
if (active?.operationId !== operationId || state?.operationId === operationId && !ACTIVE_PHASES.has(state.phase) || state?.operationId === operationId && state.phase === 'installing') return { cancelled: false }
active.controller.abort()
return { cancelled: true }
},
state: (): SkillInstallProgress | null => state ? structuredClone(state) : null,
subscribe(listener: (progress: SkillInstallProgress) => void): () => void { listeners.add(listener); return () => listeners.delete(listener) },
async initialize(): Promise<void> {
const generation = ++lifecycle
await ensureInitialized()
if (generation === lifecycle) stopping = false
},
async stop(): Promise<void> {
++lifecycle
stopping = true
if (active && (state?.operationId !== active.operationId || state.phase !== 'installing')) active.controller.abort()
await active?.task.catch(() => {})
await initialization?.catch(() => {})
}
}
}
let installer: ReturnType<typeof createRecommendedSkillInstaller> | undefined
function service(): ReturnType<typeof createRecommendedSkillInstaller> {
return installer ??= createRecommendedSkillInstaller({
tempRoot: path.join(app.getPath('userData'), 'capabilities', 'skill-installs'), catalog: SKILL_RECOMMENDATIONS,
snapshot: getSkillInstallSnapshot, importArchive: importRecommendedSkillArchive
})
}
export const getRecommendedSkills = (): RecommendedSkill[] => service().recommendations()
export const installRecommendedSkill = (id: string, options: RecommendedSkillInstallOptions): Promise<SkillRecord> => service().install(id, options)
export const cancelRecommendedSkillInstall = (id: string): { cancelled: boolean } => service().cancel(id)
export const getRecommendedSkillInstallState = (): SkillInstallProgress | null => service().state()
export const subscribeRecommendedSkillInstall = (listener: (progress: SkillInstallProgress) => void): (() => void) => service().subscribe(listener)
export const initializeRecommendedSkillInstalls = (): Promise<void> => service().initialize()
export const stopRecommendedSkillInstalls = (): Promise<void> => service().stop()
@@ -0,0 +1,12 @@
import type { RecommendedSkill } from '@shared/capabilities'
export const SKILL_RECOMMENDATION_CATALOG_VERSION = 1
export const SKILL_RECOMMENDATIONS: readonly RecommendedSkill[] = Object.freeze([
Object.freeze({
id: 'archify', name: 'archify', description: '将想法、计划或代码库转为可交互的架构图、流程图和一页式说明。',
author: 'tt-a1i', license: 'MIT', repository: 'tt-a1i/archify', version: '3.2.0',
commit: '25cf37080fc8b1dbe758a4efc3935b55f3327083', packagePath: 'archify.zip',
sha256: '70e210265c59f24dcbdf2a502ff671104b5454e7ee7d3554aecd86bad32384f5',
expectedBytes: 5271959, containsScripts: true, avatarResource: 'archify-owner'
})
])
+99 -13
View File
@@ -4,11 +4,11 @@ import path from 'node:path'
import { createHash, randomUUID } from 'node:crypto'
import { parseDocument } from 'yaml'
import { Unzip, UnzipInflate, zipSync } from 'fflate'
import type { SkillDocument, SkillRecord } from '@shared/capabilities'
import type { RecommendedSkill, SkillDocument, SkillInstallOrigin, SkillRecord } from '@shared/capabilities'
import { expertManagerDocument, expertManagerResource } from './experts'
const MAX_PACKAGE = 50 * 1024 * 1024, MAX_ARCHIVE = 20 * 1024 * 1024, MAX_FILES = 2000, MAX_DOCUMENT = 1024 * 1024
interface Entry { id: string; name: string; folder: string; enabled: boolean; digest: string }
interface Entry { id: string; name: string; folder: string; enabled: boolean; digest: string; origin?: SkillInstallOrigin }
interface Registry { version: 1; entries: Entry[]; disabledWorkspace: string[] }
interface PackageFile { name: string; data: Uint8Array }
let operations = Promise.resolve()
@@ -64,6 +64,17 @@ async function registry(): Promise<Registry> {
try {
const file = await checked(root, 'registry.json'), parsed = JSON.parse(await fs.readFile(file, 'utf8')) as Registry
if (parsed.version !== 1 || !Array.isArray(parsed.entries) || !Array.isArray(parsed.disabledWorkspace) || parsed.entries.some(entry => !/^user:[a-f0-9]{24}$/.test(entry.id) || !/^[a-f0-9]{24}$/.test(entry.folder) || typeof entry.name !== 'string' || typeof entry.enabled !== 'boolean')) throw Error('技能目录索引格式无效')
for (const entry of parsed.entries) {
const origin = entry.origin
if (origin !== undefined && (!origin || typeof origin !== 'object' ||
typeof origin.recommendationId !== 'string' || origin.recommendationId.length > 128 ||
typeof origin.repository !== 'string' || !/^[\w.-]+\/[\w.-]+$/.test(origin.repository) ||
typeof origin.commit !== 'string' || !/^[a-f\d]{40}$/.test(origin.commit) ||
typeof origin.archiveSha256 !== 'string' || !/^[a-f\d]{64}$/.test(origin.archiveSha256) ||
typeof origin.packagePath !== 'string' || origin.packagePath.length > 1024 ||
typeof origin.version !== 'string' || origin.version.length > 128 ||
!Number.isFinite(origin.installedAt) || origin.installedAt < 0)) throw Error('技能安装来源索引无效')
}
return parsed
} catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, entries: [], disabledWorkspace: [] }; throw error }
}
@@ -84,7 +95,9 @@ function metadata(content: string): { name: string; description: string; version
if (typeof data.name !== 'string' || !data.name.trim() || data.name.trim().length > 128) throw Error('SKILL.md 需要有效 name')
if (typeof data.description !== 'string' || !data.description.trim() || data.description.length > 8192) throw Error('SKILL.md 需要有效 description')
for (const key of ['enabled', 'disable', 'user-invocable', 'userInvocable', 'disable-model-invocation', 'disableModelInvocation']) if (data[key] !== undefined && typeof data[key] !== 'boolean') throw Error(`SKILL.md 的 ${key} 必须是布尔值`)
return { name: data.name.trim(), description: data.description.trim(), version: typeof data.version === 'string' || typeof data.version === 'number' ? String(data.version) : undefined,
const nested = data.metadata && typeof data.metadata === 'object' ? (data.metadata as Record<string, unknown>).version : undefined
const version = data.version ?? nested
return { name: data.name.trim(), description: data.description.trim(), version: typeof version === 'string' || typeof version === 'number' ? String(version) : undefined,
enabled: data.enabled !== false && data.disable !== true, userInvocable: (data['user-invocable'] ?? data.userInvocable) !== false,
disableModelInvocation: (data['disable-model-invocation'] ?? data.disableModelInvocation) === true }
}
@@ -203,13 +216,13 @@ async function listUnlocked(cwd?: string): Promise<SkillRecord[]> {
try {
await checked(managed, entry.folder)
const meta = metadata(await documentAt(folder)), files = await packageFiles(folder)
result.push(record(entry.id, folder, meta, 'user', entry.enabled, 'imported', packageDigest(files) !== entry.digest))
} catch (error) { result.push(invalidRecord(entry.id, entry.name, folder, 'user', error)) }
result.push({ ...record(entry.id, folder, meta, 'user', entry.enabled, 'imported', packageDigest(files) !== entry.digest), ...(entry.origin ? { origin: entry.origin } : {}) })
} catch (error) { result.push({ ...invalidRecord(entry.id, entry.name, folder, 'user', error), ...(entry.origin ? { origin: entry.origin } : {}) }) }
}
if (cwd) {
const workspace = await fs.realpath(path.resolve(cwd)).catch(() => undefined)
if (!workspace) return result
for (const source of ['.agents/skills', '.claude/skills']) {
for (const source of ['.agents/skills']) {
let directory: string
try { directory = await checked(workspace, source) } catch { continue }
if (!(await fs.stat(directory)).isDirectory()) continue
@@ -234,32 +247,90 @@ async function findSkill(id: string, cwd?: string): Promise<SkillRecord> {
if (found.error) throw Error(found.error)
return found
}
export function importSkill(source: string, overwrite = false): Promise<SkillRecord> {
return serialized(async () => {
const files = await fromPath(source), main = files.find(file => file.name === 'SKILL.md')
async function importFiles(files: PackageFile[], overwrite: boolean, origin?: SkillInstallOrigin,
expectedFingerprint?: string, beforeCommit?: () => void): Promise<SkillRecord> {
const main = files.find(file => file.name === 'SKILL.md')
if (!main || main.data.length > MAX_DOCUMENT) throw Error('技能根目录需要有效 SKILL.md')
const meta = metadata(text(main.data)), name = meta.name.normalize('NFKC').toLowerCase(), folder = hash(name).slice(0, 24), id = 'user:' + folder
const state = await registry(), existing = state.entries.find(entry => entry.id === id)
if (existing && !overwrite) throw Error('已存在同名技能,确认替换后重试')
if (expectedFingerprint !== undefined && (await snapshotUnlocked(meta.name)).fingerprint !== expectedFingerprint) throw Error('本地技能在安装期间已变化,请重新确认替换')
const packages = path.join(rootPath(), 'packages'), destination = path.join(packages, folder), stage = path.join(packages, '.stage-' + randomUUID()), backup = path.join(packages, '.backup-' + randomUUID())
let moved = false, committed = false
await fs.mkdir(stage)
try {
for (const file of files) { const target = path.join(stage, relativeName(file.name)); await fs.mkdir(path.dirname(target), { recursive: true }); await fs.writeFile(target, file.data, { flag: 'wx' }) }
if (expectedFingerprint !== undefined && (await snapshotUnlocked(meta.name)).fingerprint !== expectedFingerprint) throw Error('本地技能在安装期间已变化,请重新确认替换')
beforeCommit?.()
if (existing) {
try { await checked(packages, folder); await fs.rename(destination, backup); moved = true }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error }
}
await fs.rename(stage, destination)
const entry: Entry = { id, name: meta.name, folder, enabled: existing?.enabled ?? true, digest: packageDigest(files) }
const entry: Entry = { id, name: meta.name, folder, enabled: existing?.enabled ?? true, digest: packageDigest(files), ...(origin ? { origin } : {}) }
state.entries = [...state.entries.filter(item => item.id !== id), entry]
try { await saveRegistry(state); committed = true } catch (error) { await fs.rm(destination, { recursive: true, force: true }); throw error }
return record(id, destination, meta, 'user', entry.enabled, 'imported', false)
return { ...record(id, destination, meta, 'user', entry.enabled, 'imported', false), ...(origin ? { origin } : {}) }
} finally {
await fs.rm(stage, { recursive: true, force: true })
await fs.rm(stage, { recursive: true, force: true }).catch(error => console.error('技能暂存清理失败', error))
if (moved && !committed) await fs.rename(backup, destination)
if (committed) await fs.rm(backup, { recursive: true, force: true })
if (committed) await fs.rm(backup, { recursive: true, force: true }).catch(error => console.error('技能备份清理失败', error))
}
}
export function importSkill(source: string, overwrite = false): Promise<SkillRecord> {
return serialized(async () => importFiles(await fromPath(source), overwrite))
}
async function snapshotUnlocked(name: string): Promise<{ skill?: SkillRecord; fingerprint: string }> {
const normalized = name.normalize('NFKC').toLowerCase()
const state = await registry()
const entry = state.entries.find(item => item.name.normalize('NFKC').toLowerCase() === normalized)
if (!entry) return { fingerprint: 'absent' }
const folder = path.join(rootPath(), 'packages', entry.folder)
let digest = '', skill: SkillRecord
try {
digest = packageDigest(await packageFiles(folder))
skill = record(entry.id, folder, metadata(await documentAt(folder)), 'user', entry.enabled, 'imported', digest !== entry.digest)
} catch (error) {
if (!digest) digest = `invalid:${String(error)}`
skill = invalidRecord(entry.id, entry.name, folder, 'user', error)
}
if (entry.origin) skill.origin = entry.origin
return { skill, fingerprint: hash(JSON.stringify({ entry, digest })) }
}
export function getSkillInstallSnapshot(name: string): Promise<{ skill?: SkillRecord; fingerprint: string }> {
return serialized(() => snapshotUnlocked(name))
}
function normalizedVersion(version: unknown): string | undefined {
if (typeof version !== 'string' || !/^\d+\.\d+(?:\.\d+)?$/.test(version)) return undefined
const parts = version.split('.').map(Number)
return [parts[0], parts[1], parts[2] ?? 0].join('.')
}
export function importRecommendedSkillArchive(source: string, recommendation: RecommendedSkill,
overwrite: boolean, expectedFingerprint: string, beforeCommit: () => void): Promise<SkillRecord> {
return serialized(async () => {
const stat = await fs.lstat(source)
if (!stat.isFile() || stat.isSymbolicLink() || stat.size > MAX_ARCHIVE) throw Error('推荐技能包文件无效或过大')
const bytes = await fs.readFile(source)
if (hash(bytes) !== recommendation.sha256) throw Error('推荐技能包 SHA-256 校验失败')
const files = archiveFiles(bytes), main = files.find(file => file.name === 'SKILL.md')
if (!main || main.data.length > MAX_DOCUMENT) throw Error('推荐技能包缺少有效 SKILL.md')
const content = text(main.data), meta = metadata(content)
const front = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content)!
const yaml = parseDocument(front[1], { uniqueKeys: true, schema: 'core' }).toJS({ maxAliasCount: 50 }) as Record<string, unknown>
if (meta.name !== recommendation.name || normalizedVersion(meta.version) !== normalizedVersion(recommendation.version) ||
!normalizedVersion(recommendation.version) || yaml.license !== recommendation.license) throw Error('推荐技能名称、版本或许可证与目录不符')
const packageFile = files.find(file => file.name === 'package.json')
if (packageFile) {
const pkg = JSON.parse(text(packageFile.data)) as Record<string, unknown>
if (pkg.name !== recommendation.name || normalizedVersion(pkg.version) !== normalizedVersion(recommendation.version) || pkg.license !== recommendation.license) throw Error('推荐技能 package.json 身份与目录不符')
}
const license = files.find(file => file.name === 'LICENSE' || file.name === 'LICENSE.md')
if (!license || !text(license.data).includes(recommendation.license === 'MIT' ? 'MIT License' : recommendation.license)) throw Error('推荐技能许可证文件与目录不符')
return importFiles(files, overwrite, { recommendationId: recommendation.id, repository: recommendation.repository,
commit: recommendation.commit, packagePath: recommendation.packagePath, archiveSha256: recommendation.sha256,
version: recommendation.version, installedAt: Date.now() }, expectedFingerprint, beforeCommit)
})
}
export function readSkill(id: string, cwd?: string): Promise<SkillDocument> {
@@ -268,6 +339,7 @@ export function readSkill(id: string, cwd?: string): Promise<SkillDocument> {
}
export function setSkillEnabled(id: string, enabled: boolean, cwd?: string): Promise<void> {
return serialized(async () => {
if (typeof enabled !== 'boolean') throw new Error('技能启停状态无效')
const skill = await findSkill(id, cwd), state = await registry()
if (skill.scope === 'workspace') state.disabledWorkspace = [...state.disabledWorkspace.filter(item => item !== id), ...enabled ? [] : [id]]
else state.entries = state.entries.map(entry => entry.id === id ? { ...entry, enabled } : entry)
@@ -335,3 +407,17 @@ export function readSkillResource(id: string, resource: string, cwd: string): Pr
return text(await fs.readFile(file))
})
}
/** Gate and read under the same serialized skill operation, including per-skill invocation flags. */
export function readSkillResourceForInvocation(id: string, resource: string, cwd: string, explicit: boolean): Promise<string> {
if (id === 'builtin:expert-manager') return Promise.resolve(expertManagerResource(resource))
return serialized(async () => {
const skill = await findSkill(id, cwd)
if (!skill.enabled) throw Error('该技能已禁用')
if (explicit ? !skill.userInvocable : skill.disableModelInvocation) {
throw Error(explicit ? '该技能不允许用户手动调用' : '该技能不允许模型自动调用')
}
const file = await checked(skill.path, relativeName(resource)), stat = await fs.stat(file)
if (!stat.isFile() || stat.size > MAX_DOCUMENT) throw Error('技能资源无效或过大')
return text(await fs.readFile(file))
})
}
+136 -15
View File
@@ -18,6 +18,9 @@ import { createInteractionBroker } from './interaction-broker'
import { agentMode, agentModeInstructions } from './agent/mode-policy'
import { cachedClaudeKernel, getClaudeKernel, releaseClaudeKernel } from './agent/claude/claude-kernel'
import { normalizeUserContent } from '../shared/user-content'
import { buildSkillContext, listSkills } from './capabilities/skills'
import { discoverMcpCapabilities } from './capabilities/mcp'
import { resolveQueuedRequestSettings } from './agent/queued-request-settings'
export interface ChatTurnCompletion {
runId: string
@@ -30,6 +33,7 @@ export interface StartedChatTurn extends SendResult {
completion: Promise<ChatTurnCompletion>
}
export interface ChatTurnServicePorts {
hasQueuedPrompts?(sessionId: string, queueEditId?: string): Promise<boolean>
projects: ProjectRepository
sessionRoot(sessionId: string): Promise<string>
broadcastChat(channel: string, payload: unknown, sender?: WebContents): void
@@ -92,25 +96,53 @@ function contextWindowOf(model: { profile: { contextWindowTokens?: number; maxIn
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined
}
export function createChatTurnService({ projects: projectRepository, sessionRoot, broadcastChat, getDefaultRecipient }: ChatTurnServicePorts) {
const startTurn = async (payload: SendPayload, sender?: WebContents): Promise<StartedChatTurn> => withChatSessionLock(payload.sessionId, async () => {
export function createChatTurnService({ projects: projectRepository, sessionRoot, broadcastChat, getDefaultRecipient, hasQueuedPrompts }: ChatTurnServicePorts) {
const appenders = new Map<string, { runId: string; prepare(payload: SendPayload, id: string): Promise<{ delivery: Promise<void> }> }>()
const startTurn = async (payload: SendPayload, sender?: WebContents, queuedMessageId?: string): Promise<StartedChatTurn> => withChatSessionLock(payload.sessionId, async () => {
const recipient = sender ?? getDefaultRecipient?.()
const { sessionId, text } = payload
if (payload.rollbackArtifacts !== undefined && typeof payload.rollbackArtifacts !== 'boolean') throw new Error('产物回退选项无效')
if (!(await sessions.listSessions()).some((session) => session.id === sessionId)) throw new Error('会话不存在')
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length && !payload.quotes?.length && !payload.resources?.length)) throw new Error('消息为空')
if (typeof text !== 'string' || (!text.trim() && !payload.attachments?.length && !payload.quotes?.length && !payload.resources?.length && !payload.skillIds?.length && !payload.mcpServerIds?.length)) throw new Error('消息为空')
if ([...runTasks.values()].some((run) => run.sessionId === sessionId)) throw new Error('该会话正在运行')
if (!queuedMessageId && await hasQueuedPrompts?.(sessionId, payload.command ? undefined : payload.queueEditId)) throw new Error('请先处理待发送队列;普通消息请加入队列,压缩或重发不能绕过队列')
if (payload.command !== undefined && payload.command !== 'compact') throw new Error('不支持的内置命令')
if (payload.command && (text.trim() !== '/compact' || payload.attachments?.length || payload.quotes?.length || payload.resources?.length || payload.skillIds?.length || payload.mcpServerIds?.length || payload.action)) throw new Error('上下文压缩不能与普通消息、附件或能力引用混发')
const stored = await sessions.getMessages(sessionId)
if (payload.command && !stored.some(message => message.role === 'assistant' && message.claudeEntryId)) throw new Error('已有内核历史后才能压缩上下文')
if (stored.some(message => message.interactions?.some(interaction => ['pending', 'responding', 'error'].includes(interaction.status)))) throw new Error('请先处理当前问答或授权')
const deliveryMessageId = queuedMessageId ?? payload.queueEditId
if (deliveryMessageId && stored.some(message => message.id === deliveryMessageId)) throw new Error('追加消息已在历史中,请核对后删除队列项,不能重复交付')
const attachments = await Promise.all((payload.attachments ?? []).map((item) => getAttachment(item.id)))
const promptAttachments = await attachmentPrompt(attachments)
const context = await sessions.getSessionContext(sessionId)
const project = context.projectId ? await projectRepository.get(context.projectId) : undefined
const mode = agentMode(context.agentMode)
const permissionMode = payload.permissionMode ?? 'default-sandbox'
const { permissionMode, persistSessionPermission } = resolveQueuedRequestSettings({ queued: !!queuedMessageId,
payloadPermissionMode: payload.permissionMode, sessionPermissionMode: context.permissionMode })
if (persistSessionPermission) await sessions.updateSessionContext(sessionId, { permissionMode })
const selectedModel = await models.resolveModel(context.modelId)
if (!selectedModel) throw new Error('请先添加并选择模型')
if (!isSupportedClaudeModel(selectedModel)) throw new Error('首版仅支持 Anthropic/Claude 兼容模型')
const workspaceRoot = await sessionRoot(sessionId)
if (payload.skillIds !== undefined && (!Array.isArray(payload.skillIds) || payload.skillIds.some(id => typeof id !== 'string') || payload.skillIds.length > 100)) throw new Error('技能引用无效')
if (payload.mcpServerIds !== undefined && (!Array.isArray(payload.mcpServerIds) || payload.mcpServerIds.some(id => typeof id !== 'string') || payload.mcpServerIds.length > 100)) throw new Error('MCP 服务引用无效')
const selectedSkillIds = [...new Set(payload.skillIds ?? [])]
const selectedMcpIds = [...new Set(payload.mcpServerIds ?? [])]
const selectedSkillContext = await buildSkillContext(selectedSkillIds, workspaceRoot)
const skillRecords = selectedSkillIds.length ? await listSkills(workspaceRoot) : []
const selectedSkills = selectedSkillIds.map(id => {
const record = skillRecords.find(skill => skill.id === id)!
return { id, name: record.name, uri: record.path }
})
if (selectedMcpIds.length) {
const servers = await discoverMcpCapabilities()
for (const id of selectedMcpIds) {
const server = servers.find(item => item.id === id)
if (!server?.available) throw new Error(server?.reason ?? '所选 MCP 服务不存在或已禁用')
}
}
const references = [...new Set(payload.resources ?? [])]
for (const path of references) {
if (typeof path !== 'string' || await classifyWorkspacePath(workspaceRoot, path) === 'unknown') {
@@ -142,6 +174,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
}
const target = history[targetIndex]
if (!target || target.role !== 'user') throw new Error('未找到对应用户消息')
if (target.command) throw new Error('压缩记录不能编辑或重新生成,请使用新的原生压缩命令')
const prefix = history.slice(0, targetIndex)
forkAt = [...prefix].reverse().find((message) => message.role === 'assistant' && message.claudeEntryId)?.claudeEntryId
forceNew = !forkAt
@@ -159,8 +192,8 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
}
const runId = randomUUID()
const userMessage: ChatMessage = {
id: randomUUID(),
let userMessage: ChatMessage = {
id: deliveryMessageId ?? randomUUID(),
role: 'user',
content: text,
requestId: runId,
@@ -168,7 +201,10 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
attachments,
resources: references,
quotes: payload.quotes?.length ? payload.quotes : undefined,
userContent: normalizeUserContent(payload.userContent, text, { attachments, resources: references, quotes: payload.quotes, skills: [] }),
skills: selectedSkills.length ? selectedSkills : undefined,
mcpServerIds: selectedMcpIds.length ? selectedMcpIds : undefined,
command: payload.command,
userContent: normalizeUserContent(payload.userContent, text, { attachments, resources: references, quotes: payload.quotes, skills: selectedSkills }),
createdAt: Date.now(),
status: 'done'
}
@@ -187,7 +223,9 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
await sessions.startTurn(sessionId, userMessage, assistant, history, payload.action)
const quoteText = (payload.quotes ?? []).map((quote) => quote.markdown.trim()).filter(Boolean).join('\n\n')
const promptText = [text, quoteText, promptAttachments.text, references.length ? `引用工作区资源:\n${references.join('\n')}` : '', modeReminder].filter(Boolean).join('\n\n')
const promptText = payload.command === 'compact' ? '/compact' : [text, quoteText, promptAttachments.text, selectedSkillContext,
selectedMcpIds.length ? `本消息指定 MCP 服务(尚未执行;其他已启用能力仍可使用):${selectedMcpIds.join(', ')}` : '',
references.length ? `引用工作区资源:\n${references.join('\n')}` : '', modeReminder].filter(Boolean).join('\n\n')
let eventSequence = 0
let usage: TokenUsage | undefined
@@ -196,7 +234,7 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
activeRuns.set(runId, controller)
generalSettings.updateActiveRuns(1)
const publish = (event: ChatEvent): void => broadcastChat('chat:event', { ...event, sessionId }, recipient)
const checkpoint = createMessageCheckpoint(
const createCheckpoint = () => createMessageCheckpoint(
(message) => sessions.patchMessage(sessionId, message.id, {
content: message.content, tools: message.tools, parts: message.parts, interactions: message.interactions,
status: message.status, turnStartedAt: message.turnStartedAt, turnDurationMs: message.turnDurationMs,
@@ -205,7 +243,9 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
}),
(error) => console.error('会话增量保存失败,将在回合结束时重试', error)
)
let checkpoint = createCheckpoint()
let terminalEvent: ChatEvent | null = null
let betweenSegments = false
const emit = (event: ChatEvent): void => {
const isTerminal = event.type === 'done' || event.type === 'cancelled' || event.type === 'error'
let observedEvent: ChatEvent = isTerminal
@@ -244,9 +284,69 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
onSessionId: (id) => sessions.setClaudeSessionId(sessionId, id)
})
await kernel.setPolicy(permissionMode, mode)
const preparedAppends = new Map<string, ChatMessage>()
appenders.set(sessionId, { runId, prepare: async (input, id) => {
const notSubmitted = (message: string): never => { throw Object.assign(new Error(message), { submitted: false }) }
if (controller.signal.aborted || !kernel.busy || payload.command || input.command || input.action) notSubmitted('当前运行不能接收引导消息')
const pendingInteraction = () => assistant.interactions?.some(item => ['pending', 'responding', 'error'].includes(item.status))
if (pendingInteraction()) notSubmitted('请先处理当前问答或授权')
if (preparedAppends.has(id) || (await sessions.getMessages(sessionId)).some(message => message.id === id)) notSubmitted('追加消息已提交,不能重复执行')
const files = await Promise.all((input.attachments ?? []).map(item => getAttachment(item.id)))
const attachment = await attachmentPrompt(files)
const paths = [...new Set(input.resources ?? [])]
for (const path of paths) if (typeof path !== 'string' || await classifyWorkspacePath(workspaceRoot, path) === 'unknown') notSubmitted('引用资源已不存在或不属于当前工作区')
const skillIds = [...new Set(input.skillIds ?? [])]
const skillContext = await buildSkillContext(skillIds, workspaceRoot)
const records = skillIds.length ? await listSkills(workspaceRoot) : []
const selected = skillIds.map(skillId => { const skill = records.find(item => item.id === skillId)!; return { id: skillId, name: skill.name, uri: skill.path } })
const servers = [...new Set(input.mcpServerIds ?? [])]
if (servers.length) {
const catalog = await discoverMcpCapabilities()
for (const serverId of servers) if (!catalog.some(item => item.id === serverId && item.available)) notSubmitted('所选 MCP 服务已禁用、未连接或尚未批准')
}
if (controller.signal.aborted || pendingInteraction()) notSubmitted('运行已取消或正在等待问答/授权')
await commitAttachments(files)
const message: ChatMessage = { id, role: 'user', content: input.text, createdAt: Date.now(), status: 'done', requestId: runId,
attachments: files, resources: paths, quotes: input.quotes, skills: selected.length ? selected : undefined,
mcpServerIds: servers.length ? servers : undefined,
userContent: normalizeUserContent(input.userContent, input.text, { attachments: files, resources: paths, quotes: input.quotes, skills: selected }) }
preparedAppends.set(id, message)
const prompt = [input.text, ...(input.quotes ?? []).map(quote => quote.markdown), attachment.text, skillContext,
paths.length ? `引用工作区资源:\n${paths.join('\n')}` : '', servers.length ? `本消息指定 MCP 服务(尚未执行):${servers.join(', ')}` : '', modeReminder].filter(Boolean).join('\n\n')
const delivery = kernel.appendInput({ userMessageId: id, prompt, images: attachment.images, skillIds, mcpServerIds: servers }).catch(error => {
if ((error as { submitted?: boolean })?.submitted === false) preparedAppends.delete(id)
throw error
})
// The lock owner returns this promise as data; acknowledgement must be awaited outside the session lock.
return { delivery }
} })
const result = await kernel.runTurn({
runId, userMessageId: userMessage.id, prompt: promptText, images: promptAttachments.images,
emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id
runId, userMessageId: userMessage.id, prompt: promptText, command: payload.command,
skillIds: selectedSkillIds, mcpServerIds: selectedMcpIds, images: promptAttachments.images,
emit, interactions: interactionBroker, signal: controller.signal, ownerWebContentsId: recipient?.id,
onSegmentComplete: async result => {
assistant = { ...assistant, content: result.content, tools: result.tools, claudeEntryId: result.entryId,
status: 'done', turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt), turnStartedAt: undefined }
betweenSegments = true
await checkpoint.finish(assistant)
},
onSegmentStart: async inputs => {
if (controller.signal.aborted) throw new Error('运行已取消,追加交付未确认')
const users = inputs.map(input => preparedAppends.get(input.userMessageId))
if (!users.length || users.some(message => !message)) throw new Error('内核返回了未知追加消息身份')
const nextHistory = [...history, userMessage, assistant, ...(users.slice(0, -1) as ChatMessage[])]
const nextUser = users.at(-1)!
const nextAssistant: ChatMessage = { id: randomUUID(), role: 'assistant', content: '', ...freezeAssistantModelMetadata(selectedModel.profile),
requestId: runId, tools: [], parts: [], createdAt: Date.now(), turnStartedAt: nextUser.createdAt, status: 'streaming' }
await sessions.startTurn(sessionId, nextUser, nextAssistant, nextHistory)
history = nextHistory; userMessage = nextUser; assistant = nextAssistant
betweenSegments = false
for (const input of inputs) preparedAppends.delete(input.userMessageId)
usage = undefined
checkpoint = createCheckpoint()
++eventSequence
broadcastChat('chat:run-started', { ...runSnapshot(), segmentBoundary: true }, recipient)
}
})
assistant.content = result.content
assistant.tools = result.tools
@@ -256,7 +356,10 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
await sessions.clearClaudeFork(sessionId)
} catch (error) {
const failure = error instanceof Error ? error : new Error(String(error))
if (failure.name === 'AbortError' || controller.signal.aborted) emit({ runId, type: 'cancelled' })
if (betweenSegments) {
terminalEvent = failure.name === 'AbortError' || controller.signal.aborted
? { runId, type: 'cancelled' } : { runId, type: 'error', message: failure.message }
} else if (failure.name === 'AbortError' || controller.signal.aborted) emit({ runId, type: 'cancelled' })
else {
if (/Resume rejected by --resume-drops-turn:/i.test(failure.message)) {
await sessions.setClaudeFork(sessionId, undefined, true).catch(() => undefined)
@@ -269,15 +372,20 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
try {
if (terminalEvent) {
terminalEvent = { ...terminalEvent, sequence: ++eventSequence, turnDurationMs: Math.max(0, Date.now() - userMessage.createdAt) }
assistant = applyAssistantEvent(assistant, terminalEvent)
if (!betweenSegments) assistant = applyAssistantEvent(assistant, terminalEvent)
}
await checkpoint.finish(assistant)
if (terminalEvent) publish(terminalEvent)
if (betweenSegments) {
++eventSequence
broadcastChat('chat:run-started', { ...runSnapshot(), segmentBoundary: true }, recipient)
}
} catch (error) {
const failure: ChatEvent = { runId, type: 'error', sequence: ++eventSequence, message: `会话保存失败:${error instanceof Error ? error.message : String(error)}` }
assistant = applyAssistantEvent(assistant, failure)
publish(failure)
} finally {
if (appenders.get(sessionId)?.runId === runId) appenders.delete(sessionId)
activeRuns.delete(runId)
generalSettings.updateActiveRuns(-1)
runEmitters.delete(runId)
@@ -287,11 +395,11 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
})
runTasks.set(runId, { sessionId, recipientId: recipient?.id, promise: task, snapshot: runSnapshot })
broadcastChat('chat:run-started', runSnapshot(), recipient)
void task.finally(() => runTasks.delete(runId))
void task.then(() => runTasks.delete(runId), () => runTasks.delete(runId))
const completion = task.then((): ChatTurnCompletion => ({
runId, sessionId, assistantMessageId: assistant.id,
status: assistant.status === 'done' ? 'done' : assistant.status === 'cancelled' ? 'cancelled' : 'error',
status: terminalEvent?.type === 'cancelled' ? 'cancelled' : terminalEvent?.type === 'error' ? 'error' : assistant.status === 'done' ? 'done' : 'error',
...(assistant.error ? { error: assistant.error } : {})
}), (error): ChatTurnCompletion => ({
runId, sessionId, assistantMessageId: assistant.id, status: 'error',
@@ -355,6 +463,19 @@ export function createChatTurnService({ projects: projectRepository, sessionRoot
return {
startTurn,
async appendToRun(payload: SendPayload, id: string): Promise<void> {
let prepared: { delivery: Promise<void> }
try {
prepared = await withChatSessionLock(payload.sessionId, async () => {
const active = appenders.get(payload.sessionId)
if (!active) throw new Error('当前运行尚未准备好接收引导消息')
return active.prepare(payload, id)
})
} catch (error) {
throw Object.assign(error instanceof Error ? error : new Error(String(error)), { submitted: false })
}
await prepared.delivery
},
rewindFiles,
revert,
cancel(runId: string) {
+11 -4
View File
@@ -1,10 +1,11 @@
import type { ChatMessage, ContextUsageInfo, ContextUsageSnapshot } from '../shared/types'
import type { ChatMessage, ContextUsageBreakdown, ContextUsageInfo, ContextUsageSnapshot } from '../shared/types'
import { contextInfoFromMessages } from '../shared/context-usage'
export interface ContextUsageDependencies {
messages(id:string):Promise<ChatMessage[]>
modelId(id:string):Promise<string|undefined>
running(id:string):boolean
summary?(id:string):Promise<ContextUsageBreakdown|undefined>
/** The host rechecks identity inside its existing session mutation lock. */
persistIfCurrent(id:string,assistantId:string,requestId:string|undefined,sample:ContextUsageSnapshot):Promise<void>
}
@@ -12,13 +13,19 @@ const latestAssistant=(messages:ChatMessage[])=>messages.findLast(message=>messa
export function createContextUsageService(deps:ContextUsageDependencies):{get(id:string):Promise<ContextUsageInfo>} {
return {async get(id) {
const messages=await deps.messages(id)
let messages=await deps.messages(id)
let summary:ContextUsageBreakdown|undefined
try { summary=await deps.summary?.(id) } catch { /* Retain real stream samples on query failure. */ }
if(summary)messages=await deps.messages(id)
const assistant=latestAssistant(messages)
const info=contextInfoFromMessages(messages)
let info=contextInfoFromMessages(messages)
if(summary&&summary.requestId===assistant?.requestId&&summary.modelId===assistant?.modelId)info={...info,breakdown:summary,status:info.sample?'latest':'unavailable',reason:'分类来自内核 summary 的本地估计'}
const selectedModelId=await deps.modelId(id)
const running=deps.running(id)
const decorate=(value:ContextUsageInfo):ContextUsageInfo=>{
const model=value.sample?.modelId??value.breakdown?.modelId??assistant?.modelId
return model&&selectedModelId&&model!==selectedModelId?{...value,status:'previous',reason:'来自切换模型前的最近请求'}:value
return model&&selectedModelId&&model!==selectedModelId&&!running&&assistant?.status!=='streaming'
?{...value,status:'previous',reason:'来自切换模型前的最近请求'}:value
}
if(!assistant)return {status:'unavailable',reason:'发送消息后可查看用量'}
return decorate(info)
+30
View File
@@ -15,6 +15,11 @@ import { getPreventSleep } from './config'
import { configurePreventSleep } from './general-settings'
import { executeBrowserTool, installBrowserAutomationLifecycle } from './browser-automation'
import { configureBrowserTool } from './agent/tools/browser'
import { registerUpdates } from './updates'
import { activeRuns, runTasks } from './chat-turn-service'
import { disposeTerminalHosts } from './terminal-host'
import { pauseRuntimeStarts, resumeRuntimeStarts } from './update-maintenance'
import { createUpdateLifecycle } from './update-lifecycle'
let mainWindow: BrowserWindow | null = null
let settingsWindow: BrowserWindow | null = null
@@ -27,6 +32,7 @@ let settingsRequested = false
let settingsRequestId = 0
let allowQuit = false
let shutdownPromise: Promise<void> | null = null
let updates: ReturnType<typeof registerUpdates> | undefined
const mainDirectory = dirname(fileURLToPath(import.meta.url))
/**
@@ -271,6 +277,7 @@ app.on('before-quit', (event) => {
if (allowQuit) return
event.preventDefault()
if (shutdownPromise) return
updates?.dispose()
shutdownPromise = stopBackgroundServices().finally(() => disposeClaudeKernels()).finally(() => disposeMcpServers())
void shutdownPromise
.catch(() => undefined)
@@ -309,6 +316,29 @@ app.whenReady().then(() => {
// 通用设置:启动时套用持久化偏好(运行任务时阻止系统休眠)。
void getPreventSleep().then((enabled) => configurePreventSleep(enabled)).catch(() => undefined)
registerIpc(() => mainWindow)
const updateLifecycle = createUpdateLifecycle({
quiesce: async () => {
await pauseRuntimeStarts()
for (const controller of activeRuns.values()) controller.abort()
await stopBackgroundServices()
await Promise.all([...runTasks.values()].map(task => task.promise))
},
restore: () => { resumeRuntimeStarts(); startBackgroundServices() },
shutdown: async () => {
updates?.dispose()
disposeTerminalHosts()
await disposeClaudeKernels()
await disposeMcpServers()
},
exit: () => app.exit(0),
log: error => console.error('更新退出时清理失败', error)
})
updates = registerUpdates({
beforeInstall: updateLifecycle.beforeInstall,
installFailed: updateLifecycle.installFailed,
quitForUpdate: updateLifecycle.quitForUpdate
})
updates.start()
startBackgroundServices()
setTaskWindowFactory((sessionId) => { openTaskWindow(sessionId) })
ipcMain.handle('app:openSettings', (_event, section?: string, action?: 'new') => openSettings(section, action))
+151 -15
View File
@@ -18,7 +18,7 @@ import { classifyWorkspacePath, decodeDownload, imageDownloadPayload, readWorksp
import type { DownloadPayload, DownloadResult, ResourceRange } from '@shared/resources'
import type { InteractionResponse } from '@shared/conversation'
import { respondToInteraction } from './interaction-broker'
import { getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments'
import { commitAttachments, getAttachment, removeAttachment, stageAttachment, stageAttachmentPath } from './attachments'
import { transcribeAudio } from './speech'
import * as models from './models'
import * as workspaces from './workspaces'
@@ -39,7 +39,7 @@ import { modelMetadata, modelMetadataStatus, refreshModelMetadata } from './mode
import { readGitBranch } from './git'
import { createChatTurnService, activeRuns, runTasks, runEmitters, withChatSessionLock,
disposeSessionKernel, cancelRunsForWebContents } from './chat-turn-service'
import { claudeRuntimeInfo, setSessionVisible, removeSessionView } from './agent/claude/claude-kernel'
import { cachedClaudeKernel, claudeRuntimeInfo, setSessionVisible, removeSessionView } from './agent/claude/claude-kernel'
import { createManagedSession } from './session-factory'
import { createAutomationHost } from './automations/host'
import type { AutomationDefinitionCreateInput, AutomationDefinitionPatch, AutomationRunFilter } from '@shared/automation'
@@ -48,11 +48,27 @@ import { ArtifactIndexService } from './library/artifact-service'
import { LibraryService } from './library/service'
import { JsonLibraryUserStateRepository, libraryUserStatePath } from './library/user-state'
import { createContextUsageService } from './context-usage-service'
import { nextRequestSettings } from './agent/request-settings'
import { createPromptQueue } from './prompt-queue'
import * as skills from './capabilities/skills'
import { getRecommendedSkills, installRecommendedSkill, cancelRecommendedSkillInstall,
getRecommendedSkillInstallState, subscribeRecommendedSkillInstall,
initializeRecommendedSkillInstalls, stopRecommendedSkillInstalls } from './capabilities/recommended-skills'
import type { RecommendedSkillInstallOptions } from '@shared/capabilities'
import * as mcp from './capabilities/mcp'
import { contextInfoFromMessages } from '@shared/context-usage'
import { assertRuntimeAvailable, withRuntimeStart } from './update-maintenance'
let automationHost: ReturnType<typeof createAutomationHost> | undefined
export function startBackgroundServices(): void { automationHost?.start() }
export async function stopBackgroundServices(): Promise<void> { await automationHost?.stop() }
let stopInstallBroadcast: (() => void) | undefined
export function startBackgroundServices(): void {
automationHost?.start()
void mcp.initializeMcpServers().catch(error => console.error('MCP 启动连接失败', error))
void initializeRecommendedSkillInstalls().catch(error => console.error('推荐技能安装初始化失败', error))
}
export async function stopBackgroundServices(): Promise<void> {
await Promise.all([automationHost?.stop(), stopRecommendedSkillInstalls()])
}
/** 主进程入口注入:打开某任务的独立窗口。未注入时该菜单项给出诚实错误。 */
let taskWindowFactory: ((sessionId: string) => void) | null = null
@@ -75,7 +91,62 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
}
browserHost.setBrowserHostEventSink(event => broadcastChat('browser:event', event))
const projects = new ProjectRepository(join(app.getPath('userData'), 'projects.json'))
const chatTurns = createChatTurnService({ projects, sessionRoot, broadcastChat, getDefaultRecipient: () => getWindow()?.webContents })
const chatTurns = createChatTurnService({ projects, sessionRoot, broadcastChat, getDefaultRecipient: () => getWindow()?.webContents,
hasQueuedPrompts: async (id, editId) => {
const queue = await promptQueue.get(id)
return queue.items.length > 0 || !!queue.editingDraft && queue.editingDraft.id !== editId
} })
const assertQueueSession = async (id: string): Promise<void> => {
if (typeof id !== 'string' || !(await sessions.listSessions()).some(session => session.id === id)) throw new Error('会话不存在')
}
const hasPendingInteraction = async (id: string): Promise<boolean> => (await sessions.getMessages(id)).some(message =>
message.interactions?.some(interaction => ['pending', 'responding', 'error'].includes(interaction.status)))
const promptQueue = createPromptQueue({
file: join(app.getPath('userData'), 'prompt-queues.json'),
validate: async payload => {
await assertQueueSession(payload.sessionId)
if (payload.command || payload.action || payload.fromMessageId) throw new Error('命令与重新发送不能加入追加队列')
if (typeof payload.text !== 'string' || (!payload.text.trim() && !payload.attachments?.length && !payload.resources?.length && !payload.quotes?.length && !payload.skillIds?.length && !payload.mcpServerIds?.length)) throw new Error('消息为空')
if (await hasPendingInteraction(payload.sessionId)) throw new Error('请先处理当前问答或授权')
const cwd = await sessionRoot(payload.sessionId)
const labels: Record<string, string> = {}
if (payload.skillIds?.length) {
for (const skill of await skills.listSkills(cwd)) if (payload.skillIds.includes(skill.id)) labels[`skill:${skill.id}`] = skill.name
}
if (payload.mcpServerIds?.length) {
for (const server of await mcp.listMcpServers()) if (payload.mcpServerIds.includes(server.id)) labels[`mcp:${server.id}`] = server.name
}
payload.capabilityLabels = labels
const files = await Promise.all((payload.attachments ?? []).map(item => getAttachment(item.id)))
await commitAttachments(files)
payload.attachments = files.map(({ previewUrl: _preview, ...file }) => file)
},
blocked: async id => [...runTasks.values()].some(run => run.sessionId === id) || await hasPendingInteraction(id),
deliver: (payload, id) => withRuntimeStart(() => chatTurns.startTurn(payload, undefined, id)),
deliverNow: (payload, id) => chatTurns.appendToRun(payload, id),
changed: snapshot => broadcastChat('chat:queue:changed', snapshot)
})
ipcMain.handle('chat:queue:get', async (_event, id: string) => { await assertQueueSession(id); return promptQueue.get(id) })
ipcMain.handle('chat:queue:enqueue', (_event, payload: SendPayload) => withRuntimeStart(() => promptQueue.enqueue(payload)))
ipcMain.handle('chat:queue:restore-edit', async (_event, sessionId: string, id: string) => {
await assertQueueSession(sessionId)
if (typeof id !== 'string') throw new Error('编辑取回标识无效')
return promptQueue.restoreEdit(sessionId, id)
})
for (const action of ['take', 'remove', 'reorder', 'resume', 'sendNow'] as const) {
const channel = action === 'sendNow' ? 'send-now' : action
ipcMain.handle(`chat:queue:${channel}`, async (_event, id: string, argument: unknown) => {
await assertQueueSession(id)
if (action === 'resume') return withRuntimeStart(() => promptQueue.resume(id))
if (action === 'reorder') {
if (!Array.isArray(argument) || !argument.every(value => typeof value === 'string')) throw new Error('追加消息排序无效')
return promptQueue.reorder(id, argument)
}
if (typeof argument !== 'string') throw new Error('追加消息标识无效')
if (action === 'sendNow') return withRuntimeStart(() => promptQueue.sendNow(id, argument))
return promptQueue[action](id, argument)
})
}
const automation = createAutomationHost({ userData: app.getPath('userData'), projects, chatTurns,
changed: () => broadcastChat('automation:changed', undefined) })
automationHost = automation
@@ -85,7 +156,7 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('automation:set-enabled', (_event, id: string, enabled: boolean) => automation.api.setEnabled(id, enabled))
ipcMain.handle('automation:remove', (_event, id: string) => automation.api.remove(id))
ipcMain.handle('automation:runs', (_event, filter?: AutomationRunFilter) => automation.api.listRuns(filter))
ipcMain.handle('automation:run-now', (_event, id: string) => automation.api.runNow(id))
ipcMain.handle('automation:run-now', (_event, id: string) => { assertRuntimeAvailable(); return automation.api.runNow(id) })
const artifactIndex = new ArtifactIndexService({ repository: new FileArtifactIndexRepository(),
listSourceSessions: sessions.listSessions, getMessages: sessions.getMessages })
const library = new LibraryService({ artifactIndex,
@@ -109,17 +180,62 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('projects:update', (_event, projectId: string, patch: Partial<ProjectInput>) => projects.update(projectId, patch))
const capabilityMaintenance = (): never => { throw new Error('技能、专家和连接器正在维护') }
for (const channel of [
'skills:list', 'skills:read', 'skills:enable', 'skills:remove', 'skills:import', 'skills:export',
'experts:list', 'experts:read', 'experts:activate', 'experts:remove', 'experts:open-path',
'mcp:list', 'mcp:save', 'mcp:trust', 'mcp:connect', 'mcp:disconnect', 'mcp:enable',
'mcp:tool-enable', 'mcp:remove'
'experts:list', 'experts:read', 'experts:activate', 'experts:remove', 'experts:open-path'
]) ipcMain.handle(channel, capabilityMaintenance)
stopInstallBroadcast?.()
stopInstallBroadcast = subscribeRecommendedSkillInstall(progress => broadcastChat('skills:install-progress', progress))
const skillsChanged = (): void => broadcastChat('skills:changed', undefined)
ipcMain.handle('skills:recommendations', () => getRecommendedSkills())
ipcMain.handle('skills:install-state', () => getRecommendedSkillInstallState())
ipcMain.handle('skills:cancel-install', (_event, id: string) => cancelRecommendedSkillInstall(id))
ipcMain.handle('skills:install-recommended', async (_event, id: string, options: RecommendedSkillInstallOptions) => {
assertRuntimeAvailable()
const installed = await installRecommendedSkill(id, options)
skillsChanged()
return installed
})
ipcMain.handle('skills:list', (_event, cwd?: string) => skills.listSkills(cwd))
ipcMain.handle('skills:read', (_event, id: string, cwd?: string) => skills.readSkill(id, cwd))
ipcMain.handle('skills:enable', async (_event, id: string, enabled: boolean, cwd?: string) => {
await skills.setSkillEnabled(id, enabled, cwd)
skillsChanged()
})
ipcMain.handle('skills:remove', async (_event, id: string) => {
await skills.removeSkill(id)
skillsChanged()
})
ipcMain.handle('skills:import', async (_event, path?: string, overwrite = false, kind: 'directory' | 'zip' = 'directory') => {
if (!path) {
const selected = await dialog.showOpenDialog({ properties: [kind === 'zip' ? 'openFile' : 'openDirectory'], ...(kind === 'zip' ? { filters: [{ name: 'Skill ZIP', extensions: ['zip'] }] } : {}) })
if (selected.canceled || !selected.filePaths[0]) return null
path = selected.filePaths[0]
}
const installed = await skills.importSkill(path, overwrite)
skillsChanged()
return installed
})
ipcMain.handle('skills:export', async (_event, id: string, destination?: string) => {
if (!destination) {
const selected = await dialog.showSaveDialog({ defaultPath: 'skill.zip', filters: [{ name: 'Skill ZIP', extensions: ['zip'] }] })
if (selected.canceled || !selected.filePath) return null
destination = selected.filePath
}
return skills.exportSkill(id, destination)
})
ipcMain.handle('mcp:list', () => mcp.listMcpServers())
ipcMain.handle('mcp:save', (_event, input) => mcp.saveMcpServer(input))
ipcMain.handle('mcp:trust', (_event, id: string, hash: string) => mcp.trustMcpServer(id, hash))
ipcMain.handle('mcp:connect', (_event, id: string) => withRuntimeStart(() => mcp.connectMcpServer(id)))
ipcMain.handle('mcp:disconnect', (_event, id: string) => mcp.disconnectMcpServer(id))
ipcMain.handle('mcp:enable', (_event, id: string, enabled: boolean) => withRuntimeStart(() => mcp.setMcpEnabled(id, enabled)))
ipcMain.handle('mcp:tool-enable', (_event, id: string, tool: string, enabled: boolean) => mcp.setMcpToolEnabled(id, tool, enabled))
ipcMain.handle('mcp:remove', (_event, id: string) => mcp.removeMcpServer(id))
function senderWindow(event: Electron.IpcMainInvokeEvent): BrowserWindow {
const window = BrowserWindow.fromWebContents(event.sender)
if (!window) throw new Error('宿主窗口已关闭')
return window
}
ipcMain.handle('terminal:create', async (event, sessionId?: string, cols?: number, rows?: number) => terminalHost.createTerminal(senderWindow(event), await sessionRoot(sessionId), sessionId, cols, rows))
ipcMain.handle('terminal:create', async (event, sessionId?: string, cols?: number, rows?: number) => withRuntimeStart(async () => terminalHost.createTerminal(senderWindow(event), await sessionRoot(sessionId), sessionId, cols, rows)))
ipcMain.handle('terminal:get', (event, id: string, sessionId?: string) => terminalHost.getTerminal(senderWindow(event), id, sessionId))
ipcMain.handle('terminal:write', (event, id: string, data: string, sessionId?: string) => terminalHost.writeTerminal(senderWindow(event), id, data, sessionId))
ipcMain.handle('terminal:resize', (event, id: string, cols: number, rows: number, sessionId?: string) => terminalHost.resizeTerminal(senderWindow(event), id, cols, rows, sessionId))
@@ -415,7 +531,11 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('session:list-archived', () => sessions.listVisibleSessions(true))
ipcMain.handle('session:create', (_event, title?: string, context: SessionContext = {}) => createManagedSession(projects, title, context))
ipcMain.handle('session:update-context', (_event, id: string, patch: SessionContext) => withChatSessionLock(id, async () => {
if ([...runTasks.values()].some((run) => run.sessionId === id)) throw new Error('请先停止当前运行')
if ([...runTasks.values()].some((run) => run.sessionId === id)) {
const next = nextRequestSettings(patch)
if (next.modelId && !(await models.resolveModel(next.modelId))) throw new Error('模型不存在')
return sessions.updateSessionContext(id, next)
}
const previous = await sessions.getSessionContext(id)
patch = agentModeContextPatch(previous, patch)
if (patch.projectId !== undefined && patch.projectId !== previous.projectId) throw new Error('请在目标项目新建任务')
@@ -431,11 +551,16 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
if (patch.modelId && !(await models.resolveModel(patch.modelId))) throw new Error('模型不存在')
if (patch.modelId !== undefined) context.modelId = patch.modelId
if (patch.thinkingLevel !== undefined) context.thinkingLevel = thinkingLevel(patch.thinkingLevel)
context.expertId = null
if (patch.permissionMode !== undefined) {
if (patch.permissionMode !== 'default-sandbox' && patch.permissionMode !== 'full-access') throw new Error('权限模式无效')
context.permissionMode = patch.permissionMode
}
if (patch.expertId !== undefined) context.expertId = patch.expertId
return sessions.updateSessionContext(id, context)
}))
ipcMain.handle('session:messages', (_event, sessionId: string) => sessions.getMessages(sessionId))
const contextUsageService=createContextUsageService({messages:sessions.getMessages,
summary: async id => cachedClaudeKernel(id)?.getContextBreakdown(),
modelId:async id=>(await sessions.getSessionContext(id)).modelId,
running:id=>[...runTasks.values()].some(run=>run.sessionId===id),
persistIfCurrent:(id,assistantId,requestId,sample)=>withChatSessionLock(id,async()=>{
@@ -472,13 +597,17 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
})
ipcMain.handle('session:delete', async (_event, sessionId: string, options?: { onlyIfEmpty?: boolean }) => withChatSessionLock(sessionId, async () => {
const tasks = [...runTasks.entries()].filter(([, task]) => task.sessionId === sessionId)
if (options?.onlyIfEmpty && (tasks.length || (await sessions.getMessages(sessionId)).length)) {
const queued = await promptQueue.get(sessionId)
if (queued.items.some(item => item.status === 'delivering')) throw new Error('追加消息正在交付,请稍后删除会话')
if (options?.onlyIfEmpty && (tasks.length || queued.items.length || queued.editingDraft || (await sessions.getMessages(sessionId)).length)) {
throw new Error('该会话已有消息或正在运行,已保留任务')
}
await promptQueue.pause(sessionId, '会话正在删除')
for (const [runId] of tasks) activeRuns.get(runId)?.abort()
await disposeSessionKernel(sessionId)
await Promise.all(tasks.map(([, task]) => task.promise))
disposeSessionBrowsers(sessionId)
await promptQueue.clear(sessionId)
return sessions.deleteSession(sessionId)
}))
@@ -515,7 +644,14 @@ export function registerIpc(getWindow: () => BrowserWindow | null): void {
ipcMain.handle('chat:revert', (_event, sessionId: string, messageId: string, options?: RewindOptions) =>
chatTurns.revert(sessionId, messageId, options))
ipcMain.handle('chat:send', async (event, payload: SendPayload) => {
const { completion: _completion, ...result } = await chatTurns.startTurn(payload, event.sender)
if (payload.queueEditId) await promptQueue.assertEditing(payload.sessionId, payload.queueEditId)
if (payload.command) {
const queue = await promptQueue.get(payload.sessionId)
if (queue.items.length || queue.editingDraft) throw new Error('请先处理待发送消息再压缩上下文')
}
const { completion, ...result } = await withRuntimeStart(() => chatTurns.startTurn(payload, event.sender))
if (payload.queueEditId) await promptQueue.completeEdit(payload.sessionId, payload.queueEditId).catch(error => console.error('编辑取回记录清理失败,已保留待核对记录', error))
void completion.then(done => done.status === 'done' ? promptQueue.notify(payload.sessionId) : promptQueue.pause(payload.sessionId, '运行停止或异常,已暂停自动发送')).catch(error => console.error('追加队列状态保存失败', error))
return result
})
+802
View File
@@ -0,0 +1,802 @@
import { randomUUID } from 'node:crypto'
import { promises as fs } from 'node:fs'
import { hostname } from 'node:os'
import { dirname } from 'node:path'
import type { SendPayload } from '../shared/ipc'
import type { PromptQueueSnapshot, QueuedPrompt } from '../shared/prompt-queue'
interface Delivery {
completion: Promise<{ status: 'done' | 'cancelled' | 'error' }>
}
interface Ports {
file: string
validate(payload: SendPayload): Promise<void>
blocked(sessionId: string): Promise<boolean>
deliver(payload: SendPayload, id: string): Promise<Delivery>
deliverNow?(payload: SendPayload, id: string): Promise<void>
/** Host-owned request settings resolved immediately before delivery; stored payload stays immutable. */
resolveDispatch?(payload: SendPayload, id: string): Promise<SendPayload> | SendPayload
changed(snapshot: PromptQueueSnapshot): void
}
interface DeliveryOwner {
pid: number
nonce: string
hostname: string
startedAt: number
}
interface StoredQueuedPrompt extends Omit<QueuedPrompt, 'status'> {
status: QueuedPrompt['status'] | 'editing'
editingUnknown?: boolean
deliveryOwner?: DeliveryOwner
}
interface StoredQueue extends Omit<PromptQueueSnapshot, 'items'> {
items: StoredQueuedPrompt[]
}
interface Store {
version: 1
queues: StoredQueue[]
}
interface LockMetadata {
pid: number
nonce: string
hostname: string
createdAt: number
expiresAt: number
}
interface MemoryOverlay {
itemId: string
error: string
reason: string
}
const MAX_ITEMS = 19
const MAX_FILE_BYTES = 10 * 1024 * 1024
const LOCK_TIMEOUT_MS = 10_000
const LOCK_LEASE_MS = 30_000
const LOCK_HEARTBEAT_MS = 10_000
const LOCAL_HOST = hostname()
/** Shared by repository instances in this process so a fresh read preserves their active delivery. */
const PROCESS_NONCE = randomUUID()
const overlays = new Map<string, MemoryOverlay>()
const lastKnownQueues = new Map<string, StoredQueue>()
const clone = <T>(value: T): T => structuredClone(value)
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
const queueKey = (file: string, sessionId: string): string => `${file}\0${sessionId}`
const empty = (sessionId: string): StoredQueue => ({ sessionId, revision: 0, paused: false, items: [] })
const object = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value)
const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error)
function fail(message: string): never {
throw new Error(`追加队列文件损坏:${message}`)
}
function validatePayload(value: unknown, sessionId: string): SendPayload {
if (!object(value)) fail('消息载荷无效')
if (value.sessionId !== sessionId) fail('消息载荷会话与队列会话不一致')
if (typeof value.text !== 'string') fail('消息文本无效')
if (value.command !== undefined || value.action !== undefined || value.fromMessageId !== undefined) {
fail('普通队列不能包含命令或重新发送操作')
}
if (value.permissionMode !== undefined && value.permissionMode !== 'default-sandbox' && value.permissionMode !== 'full-access') {
fail('权限模式无效')
}
if (value.capabilityLabels !== undefined) {
const labels = value.capabilityLabels
if (!object(labels) || Object.keys(labels).length > 100 ||
Object.entries(labels).some(([key, label]) =>
!key || key.length > 512 || typeof label !== 'string' || label.length > 1024)) {
fail('能力标签字段无效')
}
}
for (const key of ['resources', 'skillIds', 'mcpServerIds'] as const) {
if (value[key] !== undefined && (!Array.isArray(value[key]) || value[key].some(item => typeof item !== 'string'))) {
fail(`${key} 字段无效`)
}
}
const attachments = Array.isArray(value.attachments) ? value.attachments : []
if (value.attachments !== undefined) {
if (!Array.isArray(value.attachments) || attachments.some(item => !object(item) ||
typeof item.id !== 'string' || typeof item.name !== 'string' ||
typeof item.mimeType !== 'string' || typeof item.size !== 'number' || !Number.isFinite(item.size))) {
fail('附件字段无效')
}
}
const quotes = Array.isArray(value.quotes) ? value.quotes : []
if (value.quotes !== undefined) {
if (!Array.isArray(value.quotes) || quotes.some(item => !object(item) ||
typeof item.id !== 'string' || item.kind !== 'table' ||
typeof item.markdown !== 'string' || typeof item.preview !== 'string')) {
fail('引用字段无效')
}
}
if (value.userContent !== undefined && !Array.isArray(value.userContent)) fail('内容顺序字段无效')
if (value.queueEditId !== undefined &&
(typeof value.queueEditId !== 'string' || !value.queueEditId.trim() || value.queueEditId.length > 256)) {
fail('编辑取回标识无效')
}
if (value.planId !== undefined && typeof value.planId !== 'string') fail('计划标识无效')
if (value.rollbackArtifacts !== undefined && typeof value.rollbackArtifacts !== 'boolean') fail('产物回退字段无效')
if (!value.text.trim() && !attachments.length && !quotes.length &&
!(Array.isArray(value.resources) && value.resources.length) &&
!(Array.isArray(value.skillIds) && value.skillIds.length) &&
!(Array.isArray(value.mcpServerIds) && value.mcpServerIds.length)) fail('消息为空')
return value as unknown as SendPayload
}
function validateIncomingPayload(value: unknown): SendPayload {
if (!object(value) || typeof value.sessionId !== 'string' || !value.sessionId || value.sessionId.length > 256) {
throw new Error('追加消息载荷无效')
}
try {
return validatePayload(value, value.sessionId)
} catch (error) {
throw new Error(`追加消息无效:${errorMessage(error).replace(/^追加队列文件损坏:/, '')}`)
}
}
function validateStore(value: unknown): Store {
if (!object(value) || value.version !== 1 || !Array.isArray(value.queues)) fail('版本或顶层结构无效')
const sessions = new Set<string>()
const queues: StoredQueue[] = []
for (const rawQueue of value.queues) {
if (!object(rawQueue)) fail('队列不是对象')
const sessionId = rawQueue.sessionId
if (typeof sessionId !== 'string' || !sessionId || sessionId.length > 256 || sessions.has(sessionId)) fail('会话标识缺失或重复')
sessions.add(sessionId)
if (!Number.isInteger(rawQueue.revision) || Number(rawQueue.revision) < 0) fail('revision 无效')
if (typeof rawQueue.paused !== 'boolean') fail('暂停状态无效')
if (rawQueue.reason !== undefined && typeof rawQueue.reason !== 'string') fail('暂停原因无效')
if (!Array.isArray(rawQueue.items) || rawQueue.items.length > MAX_ITEMS + 1) fail('消息数量无效')
const ids = new Set<string>()
const items: StoredQueuedPrompt[] = rawQueue.items.map(rawItem => {
if (!object(rawItem)) fail('队列消息不是对象')
if (typeof rawItem.id !== 'string' || !rawItem.id || rawItem.id.length > 256 || ids.has(rawItem.id)) fail('消息标识缺失或重复')
ids.add(rawItem.id)
if (typeof rawItem.createdAt !== 'number' || !Number.isFinite(rawItem.createdAt) || rawItem.createdAt < 0) fail('消息时间无效')
if (!['pending', 'delivering', 'unknown', 'editing'].includes(String(rawItem.status))) fail('消息状态无效')
if (rawItem.error !== undefined && typeof rawItem.error !== 'string') fail('消息错误信息无效')
const status = rawItem.status as StoredQueuedPrompt['status']
if (status !== 'unknown' && status !== 'pending' && rawItem.error !== undefined) fail('消息恢复错误状态无效')
if (rawItem.editingUnknown !== undefined && (status !== 'editing' || typeof rawItem.editingUnknown !== 'boolean')) fail('编辑交接状态无效')
let deliveryOwner: DeliveryOwner | undefined
if (rawItem.deliveryOwner !== undefined) {
if (status !== 'delivering' && status !== 'editing' || !object(rawItem.deliveryOwner)) fail('交付所有权标记无效')
const owner = rawItem.deliveryOwner
if (!Number.isInteger(owner.pid) || Number(owner.pid) <= 0 || typeof owner.nonce !== 'string' ||
!owner.nonce || typeof owner.hostname !== 'string' || !owner.hostname ||
typeof owner.startedAt !== 'number' || !Number.isFinite(owner.startedAt)) fail('交付所有权内容无效')
deliveryOwner = { pid: Number(owner.pid), nonce: owner.nonce, hostname: owner.hostname, startedAt: owner.startedAt }
}
return {
id: rawItem.id,
createdAt: rawItem.createdAt,
status,
...(rawItem.editingUnknown !== undefined ? { editingUnknown: rawItem.editingUnknown } : {}),
payload: validatePayload(rawItem.payload, sessionId),
...(rawItem.error !== undefined ? { error: rawItem.error } : {}),
...(deliveryOwner ? { deliveryOwner } : {})
}
})
if (items.filter(item => item.status !== 'editing').length > MAX_ITEMS || items.filter(item => item.status === 'editing').length > 1) fail('消息数量或编辑取回数量无效')
queues.push({
sessionId,
revision: Number(rawQueue.revision),
paused: rawQueue.paused,
...(rawQueue.reason !== undefined ? { reason: rawQueue.reason } : {}),
items
})
}
return { version: 1, queues }
}
async function readStore(file: string): Promise<Store> {
try {
const stat = await fs.stat(file)
if (!stat.isFile() || stat.size > MAX_FILE_BYTES) throw new Error('追加队列文件不是普通文件或过大')
return validateStore(JSON.parse(await fs.readFile(file, 'utf8')))
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { version: 1, queues: [] }
if (error instanceof SyntaxError) throw new Error('追加队列文件格式无效')
throw error
}
}
async function saveStore(file: string, value: Store): Promise<void> {
const serialized = JSON.stringify(value)
if (Buffer.byteLength(serialized, 'utf8') > MAX_FILE_BYTES) throw new Error('追加队列内容超过 10 MB,已保留原队列和输入草稿')
await fs.mkdir(dirname(file), { recursive: true })
const temporary = `${file}.${randomUUID()}.tmp`
try {
await fs.writeFile(temporary, serialized, { encoding: 'utf8', mode: 0o600 })
await fs.rename(temporary, file)
} catch (error) {
throw new Error(`追加队列保存失败:${errorMessage(error)}`)
} finally {
await fs.rm(temporary, { force: true }).catch(() => undefined)
}
}
function pidAlive(pid: number): boolean {
if (!Number.isInteger(pid) || pid <= 0) return false
try {
process.kill(pid, 0)
return true
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'EPERM'
}
}
async function readLock(file: string): Promise<LockMetadata | undefined> {
try {
const value = JSON.parse(await fs.readFile(file, 'utf8'))
if (!object(value) || !Number.isInteger(value.pid) || typeof value.nonce !== 'string' ||
typeof value.hostname !== 'string' || typeof value.createdAt !== 'number' ||
typeof value.expiresAt !== 'number') return undefined
return value as unknown as LockMetadata
} catch {
return undefined
}
}
async function staleLock(lockPath: string): Promise<boolean> {
const metadata = await readLock(lockPath)
if (metadata) {
// A live local process is never preempted merely because its heartbeat
// stalled; callers get a clear timeout instead. Other hosts use the lease.
return metadata.hostname === LOCAL_HOST
? !pidAlive(metadata.pid)
: metadata.expiresAt <= Date.now()
}
try {
const stat = await fs.stat(lockPath)
return Date.now() - stat.mtimeMs > LOCK_LEASE_MS
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT'
}
}
async function reclaimLock(lockPath: string): Promise<boolean> {
if (!await staleLock(lockPath)) return false
const quarantine = `${lockPath}.stale.${randomUUID()}`
try {
await fs.rename(lockPath, quarantine)
} catch (error) {
return (error as NodeJS.ErrnoException).code === 'ENOENT'
}
const moved = await readLock(quarantine)
const stillActive = moved && (moved.hostname === LOCAL_HOST
? pidAlive(moved.pid)
: moved.expiresAt > Date.now())
if (stillActive) {
let restore: Awaited<ReturnType<typeof fs.open>> | undefined
try {
// Exclusive creation ensures a competing owner's lock is never replaced.
restore = await fs.open(lockPath, 'wx', 0o600)
await restore.writeFile(await fs.readFile(quarantine))
await restore.sync()
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') await fs.rm(quarantine, { force: true }).catch(() => undefined)
return false
} finally {
await restore?.close().catch(() => undefined)
}
await fs.rm(quarantine, { force: true }).catch(() => undefined)
return false
}
await fs.rm(quarantine, { force: true }).catch(() => undefined)
return true
}
async function withFileLock<T>(file: string, operation: () => Promise<T>): Promise<T> {
const lockPath = `${file}.lock`
await fs.mkdir(dirname(file), { recursive: true })
const deadline = Date.now() + LOCK_TIMEOUT_MS
let handle: Awaited<ReturnType<typeof fs.open>> | undefined
const nonce = randomUUID()
const metadata: LockMetadata = {
pid: process.pid,
nonce,
hostname: LOCAL_HOST,
createdAt: Date.now(),
expiresAt: Date.now() + LOCK_LEASE_MS
}
while (!handle) {
try {
handle = await fs.open(lockPath, 'wx', 0o600)
await handle.writeFile(JSON.stringify(metadata), 'utf8')
await handle.sync()
} catch (error) {
if (handle) {
await handle.close().catch(() => undefined)
await fs.rm(lockPath, { force: true }).catch(() => undefined)
handle = undefined
}
const code = (error as NodeJS.ErrnoException).code
const exists = code === 'EEXIST' ||
code === 'EPERM' && await fs.stat(lockPath).then(() => true, () => false)
if (!exists) throw error
if (await reclaimLock(lockPath)) continue
if (Date.now() >= deadline) throw new Error('追加队列正被其他 YCode 实例使用,等待文件锁超时')
await sleep(40)
}
}
let released = false
let heartbeatInFlight: Promise<void> | undefined
const lockHandle = handle
const refreshLock = (): void => {
if (released || heartbeatInFlight) return
const task = (async () => {
if (released) return
const bytes = Buffer.from(JSON.stringify({ ...metadata, expiresAt: Date.now() + LOCK_LEASE_MS }), 'utf8')
// Keep using the original descriptor; reopening the path could overwrite a new owner.
await lockHandle.truncate(0)
await lockHandle.write(bytes, 0, bytes.length, 0)
await lockHandle.sync()
})().catch(() => undefined).finally(() => {
if (heartbeatInFlight === task) heartbeatInFlight = undefined
})
heartbeatInFlight = task
}
const heartbeat = setInterval(() => {
refreshLock()
}, LOCK_HEARTBEAT_MS)
heartbeat.unref()
try {
return await operation()
} finally {
released = true
clearInterval(heartbeat)
await heartbeatInFlight
const current = await readLock(lockPath)
if (current?.nonce === nonce) await fs.rm(lockPath, { force: true }).catch(() => undefined)
await lockHandle.close().catch(() => undefined)
}
}
function ownerIsActive(owner: DeliveryOwner | undefined): boolean {
if (!owner) return false
if (owner.hostname !== LOCAL_HOST) return Date.now() - owner.startedAt < LOCK_LEASE_MS
if (owner.pid === process.pid) return owner.nonce === PROCESS_NONCE
return pidAlive(owner.pid)
}
function recoverQueues(state: Store): Set<string> {
const changed = new Set<string>()
for (const queue of state.queues) {
let recovered = false
for (const item of queue.items) {
if (item.status === 'editing' && !ownerIsActive(item.deliveryOwner)) {
item.status = item.editingUnknown ? 'unknown' : 'pending'
if (item.editingUnknown) item.error = '原消息交付结果不明,编辑尚未提交,请核对历史后处理'
delete item.editingUnknown
delete item.deliveryOwner
recovered = true
continue
}
if (item.status !== 'delivering' || ownerIsActive(item.deliveryOwner)) continue
item.status = 'unknown'
item.error = '上次交付结果不明,请核对历史后编辑或删除'
delete item.deliveryOwner
recovered = true
}
if (recovered) {
queue.paused = true
queue.reason = '检测到未完成的交付,已暂停自动发送'
queue.revision++
changed.add(queue.sessionId)
}
}
return changed
}
function applyOverlay(file: string, sessionId: string, queue: StoredQueue): boolean {
const overlay = overlays.get(queueKey(file, sessionId))
if (!overlay) return false
const item = queue.items.find(value => value.id === overlay.itemId)
if (!item) {
overlays.delete(queueKey(file, sessionId))
return false
}
const changed = item.status !== 'unknown' || item.error !== overlay.error ||
!queue.paused || queue.reason !== overlay.reason
if (changed) {
item.status = 'unknown'
item.error = overlay.error
delete item.deliveryOwner
delete item.editingUnknown
queue.paused = true
queue.reason = overlay.reason
queue.revision++
}
return changed
}
function overlaySessions(file: string): string[] {
const prefix = `${file}\0`
return [...overlays.keys()].filter(key => key.startsWith(prefix)).map(key => key.slice(prefix.length))
}
function lastKnownSnapshots(file: string): StoredQueue[] {
const prefix = `${file}\0`
return [...lastKnownQueues.entries()]
.filter(([key]) => key.startsWith(prefix))
.map(([, queue]) => clone(queue))
}
function snapshot(value: StoredQueue): PromptQueueSnapshot {
const toPrompt = ({ deliveryOwner: _owner, editingUnknown, status, ...item }: StoredQueuedPrompt): QueuedPrompt => ({ ...clone(item), status: status === 'editing' ? editingUnknown ? 'unknown' : 'pending' : status })
return {
sessionId: value.sessionId,
revision: value.revision,
paused: value.paused,
...(value.reason !== undefined ? { reason: value.reason } : {}),
items: value.items.filter(item => item.status !== 'editing').map(toPrompt),
...(value.items.some(item => item.status === 'editing') ? { editingDraft: (() => {
const draft = toPrompt(value.items.find(item => item.status === 'editing')!)
return { ...draft, payload: { ...draft.payload, queueEditId: draft.id } }
})() } : {})
}
}
export function createPromptQueue(ports: Ports) {
let serial: Promise<unknown> = Promise.resolve()
let loadedOnce = false
const pumping = new Set<string>()
const locked = <T>(operation: () => Promise<T>): Promise<T> => {
const result = serial.catch(() => {}).then(() => withFileLock(ports.file, operation))
serial = result.catch(() => {})
return result
}
async function load(): Promise<Store> {
let state: Store
try {
state = await readStore(ports.file)
} catch (error) {
const fallback = lastKnownSnapshots(ports.file)
if (!overlaySessions(ports.file).length || !fallback.length) throw error
state = { version: 1, queues: fallback }
for (const queue of state.queues) {
if (applyOverlay(ports.file, queue.sessionId, queue)) {
queue.reason = `${queue.reason ?? '追加队列不可用'};读取失败:${errorMessage(error)}`
}
}
return state
}
if (loadedOnce && !state.queues.length) {
// The store can disappear between two operations in the same process.
// Keep the last validated queues instead of silently dropping unrelated
// sessions; a real restart starts with an empty in-memory fallback.
state.queues = lastKnownSnapshots(ports.file)
}
for (const sessionId of overlaySessions(ports.file)) {
if (state.queues.some(queue => queue.sessionId === sessionId)) continue
const fallback = lastKnownQueues.get(queueKey(ports.file, sessionId))
if (fallback) state.queues.push(clone(fallback))
}
for (const queue of state.queues) lastKnownQueues.set(queueKey(ports.file, queue.sessionId), clone(queue))
const recovered = recoverQueues(state)
if (!loadedOnce) {
loadedOnce = true
for (const queue of state.queues) {
if (!queue.items.length || queue.paused) continue
// A live delivery owned by this process is a fresh read, not a restart.
if (queue.items.some(item => item.status === 'delivering' && ownerIsActive(item.deliveryOwner))) continue
queue.paused = true
queue.reason = '应用重启后队列已暂停,请确认后恢复'
queue.revision++
recovered.add(queue.sessionId)
}
}
if (recovered.size) {
try {
await saveStore(ports.file, state)
for (const sessionId of recovered) overlays.delete(queueKey(ports.file, sessionId))
} catch (error) {
for (const sessionId of recovered) {
const queue = state.queues.find(value => value.sessionId === sessionId)
const item = queue?.items.find(value => value.status === 'unknown')
if (!queue || !item) continue
overlays.set(queueKey(ports.file, sessionId), {
itemId: item.id,
error: `${item.error ?? '交付结果不明'};恢复状态保存失败:${errorMessage(error)}`,
reason: `${queue.reason ?? '已暂停自动发送'};恢复状态保存失败:${errorMessage(error)}`
})
}
}
}
for (const queue of state.queues) {
applyOverlay(ports.file, queue.sessionId, queue)
lastKnownQueues.set(queueKey(ports.file, queue.sessionId), clone(queue))
}
if (recovered.size) {
for (const sessionId of recovered) {
const queue = state.queues.find(value => value.sessionId === sessionId)
if (queue) {
try { ports.changed(snapshot(queue)) } catch { /* Persistence is independent of windows. */ }
}
}
}
return state
}
async function mutate<T>(sessionId: string, edit: (queue: StoredQueue) => T | Promise<T>): Promise<T> {
return locked(async () => {
const state = await load()
const current = state.queues.find(queue => queue.sessionId === sessionId)
const queue = clone(current ?? empty(sessionId))
const before = JSON.stringify(queue)
const result = await edit(queue)
if (JSON.stringify(queue) === before) return clone(result)
queue.revision++
state.queues = [...state.queues.filter(value => value.sessionId !== sessionId), queue]
await saveStore(ports.file, state)
overlays.delete(queueKey(ports.file, sessionId))
lastKnownQueues.set(queueKey(ports.file, sessionId), clone(queue))
try { ports.changed(snapshot(queue)) } catch { /* Window lifetime does not affect persistence. */ }
return clone(result)
})
}
function item(queue: StoredQueue, id: string): StoredQueuedPrompt {
const found = queue.items.find(value => value.id === id)
if (!found) throw new Error('追加消息不存在')
if (found.status === 'delivering') throw new Error('消息正在交付,不能编辑或删除')
if (found.status === 'editing') throw new Error('消息已在输入框中编辑')
return found
}
async function setUnknown(sessionId: string, itemId: string, reason: string, detail: string): Promise<void> {
try {
await mutate(sessionId, queue => {
const found = queue.items.find(value => value.id === itemId)
if (!found) return queue
found.status = 'unknown'
found.error = detail
delete found.deliveryOwner
delete found.editingUnknown
queue.paused = true
queue.reason = reason
return queue
})
} catch (error) {
const known = lastKnownQueues.get(queueKey(ports.file, sessionId))
if (!known) return
overlays.set(queueKey(ports.file, sessionId), {
itemId,
error: `${detail};暂停状态保存失败:${errorMessage(error)}`,
reason: `${reason};暂停状态保存失败:${errorMessage(error)}`
})
const fallback = clone(known)
applyOverlay(ports.file, sessionId, fallback)
lastKnownQueues.set(queueKey(ports.file, sessionId), fallback)
}
}
const pump = async (sessionId: string): Promise<void> => {
if (pumping.has(sessionId)) return
pumping.add(sessionId)
let reserved: StoredQueuedPrompt | undefined
try {
for (;;) {
reserved = await mutate(sessionId, async queue => {
if (queue.paused || !queue.items.length || await ports.blocked(sessionId)) return undefined
const first = queue.items.find(item => item.status !== 'editing')
if (!first) return undefined
if (first.status !== 'pending') return undefined
first.status = 'delivering'
delete first.error
first.deliveryOwner = {
pid: process.pid,
nonce: PROCESS_NONCE,
hostname: LOCAL_HOST,
startedAt: Date.now()
}
return clone(first)
})
if (!reserved) return
let delivery: Delivery
try {
const dispatchPayload = ports.resolveDispatch
? await ports.resolveDispatch(clone(reserved.payload), reserved.id)
: reserved.payload
if (dispatchPayload.sessionId !== sessionId) throw new Error('派发配置会话与队列会话不一致')
delivery = await ports.deliver(dispatchPayload, reserved.id)
} catch (error) {
await setUnknown(sessionId, reserved.id, '交付未确认,已暂停自动发送', errorMessage(error))
return
}
try {
await mutate(sessionId, queue => {
queue.items = queue.items.filter(value => value.id !== reserved!.id)
return queue
})
} catch (error) {
await setUnknown(sessionId, reserved.id, '交付已开始,但队列清理失败,已暂停自动发送', errorMessage(error))
return
}
reserved = undefined
let result: Awaited<Delivery['completion']>
try {
result = await delivery.completion
} catch (error) {
try {
await mutate(sessionId, queue => {
queue.paused = true
queue.reason = `运行状态读取失败:${errorMessage(error)}`
return queue
})
} catch { /* The next explicit resume reports storage failure. */ }
return
}
if (result.status !== 'done') {
try {
await mutate(sessionId, queue => {
queue.paused = true
queue.reason = '运行停止或异常,已暂停自动发送'
return queue
})
} catch (error) {
console.error('追加队列暂停状态保存失败', error)
}
return
}
}
} finally {
pumping.delete(sessionId)
}
}
const notify = (sessionId: string): void => {
void pump(sessionId).catch(error => { console.error('追加队列派发失败', error) })
}
return {
get: (sessionId: string) => locked(async () => snapshot((await load()).queues.find(queue => queue.sessionId === sessionId) ?? empty(sessionId))),
async enqueue(payload: SendPayload): Promise<PromptQueueSnapshot> {
const incoming = clone(validateIncomingPayload(payload))
const result = await mutate(incoming.sessionId, async queue => {
if (queue.items.filter(item => item.status !== 'editing').length >= MAX_ITEMS) throw new Error('最多追加 19 条消息,请先处理队列')
let insertAt = queue.items.length
if (payload.queueEditId) {
const editingIndex = queue.items.findIndex(item => item.id === payload.queueEditId && item.status === 'editing')
if (editingIndex < 0) throw new Error('取回的追加消息已提交或不再处于编辑状态')
insertAt = editingIndex
queue.items = queue.items.filter(item => item.id !== payload.queueEditId)
}
await ports.validate(incoming)
// Host enrichment (for example display labels) is also untrusted input
// until it crosses this boundary again.
const storedPayload = clone(validatePayload(incoming, incoming.sessionId))
delete storedPayload.queueEditId
// A stop can leave an empty queue paused. The next independent run starts
// with a clean dispatch state; an existing queue keeps its explicit pause.
if (queue.items.length === 0) {
queue.paused = false
delete queue.reason
}
const item = { id: randomUUID(), createdAt: Date.now(), status: 'pending' as const, payload: storedPayload }
queue.items.splice(Math.min(insertAt, queue.items.length), 0, item)
return queue
})
notify(incoming.sessionId)
return snapshot(result)
},
take: async (sessionId: string, id: string): Promise<QueuedPrompt> => locked(async () => {
const state = await load()
const current = state.queues.find(queue => queue.sessionId === sessionId)
const queue = clone(current ?? empty(sessionId))
const found = item(queue, id)
const taken = snapshot({ ...empty(sessionId), items: [found] }).items[0]
queue.items = queue.items.filter(value => value.status !== 'editing')
found.editingUnknown = found.status === 'unknown'
found.status = 'editing'
delete found.error
found.deliveryOwner = { pid: process.pid, nonce: PROCESS_NONCE, hostname: LOCAL_HOST, startedAt: Date.now() }
queue.revision++
state.queues = [...state.queues.filter(value => value.sessionId !== sessionId), queue]
await saveStore(ports.file, state)
overlays.delete(queueKey(ports.file, sessionId))
lastKnownQueues.set(queueKey(ports.file, sessionId), clone(queue))
try { ports.changed(snapshot(queue)) } catch { /* Window lifetime does not affect persistence. */ }
return { ...taken, payload: { ...taken.payload, queueEditId: id } }
}),
restoreEdit: async (sessionId: string, id: string): Promise<PromptQueueSnapshot> => mutate(sessionId, queue => {
const found = queue.items.find(item => item.id === id && item.status === 'editing')
if (!found) throw new Error('编辑取回记录已不存在')
found.status = found.editingUnknown ? 'unknown' : 'pending'
if (found.editingUnknown) found.error = '原消息交付结果不明,请核对历史后处理'
delete found.editingUnknown; delete found.deliveryOwner
queue.paused = true; queue.reason = '编辑取回未完成,原消息已保留,请确认后恢复'
return queue
}).then(snapshot),
async assertEditing(sessionId: string, id: string): Promise<void> {
await locked(async () => {
if (!(await load()).queues.find(queue => queue.sessionId === sessionId)?.items.some(item => item.id === id && item.status === 'editing')) throw new Error('编辑取回记录已提交或不存在')
})
},
async completeEdit(sessionId: string, id: string): Promise<void> {
try {
await mutate(sessionId, queue => { queue.items = queue.items.filter(item => item.id !== id || item.status !== 'editing') })
} catch (error) {
await setUnknown(sessionId, id, '编辑消息已提交,但取回记录清理失败,请核对历史', errorMessage(error))
throw error
}
},
remove: async (sessionId: string, id: string): Promise<PromptQueueSnapshot> => mutate(sessionId, queue => {
item(queue, id)
queue.items = queue.items.filter(value => value.id !== id)
return queue
}).then(snapshot),
reorder: async (sessionId: string, ids: string[]): Promise<PromptQueueSnapshot> => mutate(sessionId, queue => {
if (!Array.isArray(ids) || ids.length !== queue.items.filter(item => item.status !== 'editing').length || new Set(ids).size !== ids.length ||
ids.some(id => !queue.items.some(value => value.id === id && value.status !== 'editing'))) throw new Error('追加消息排序无效')
if (queue.items.some(value => value.status === 'delivering')) throw new Error('正在交付时不能排序')
queue.items = [...ids.map(id => queue.items.find(value => value.id === id)!), ...queue.items.filter(item => item.status === 'editing')]
return queue
}).then(snapshot),
async resume(sessionId: string): Promise<PromptQueueSnapshot> {
const result = await mutate(sessionId, queue => {
if (queue.items.some(value => value.status === 'unknown')) throw new Error('请先核对并编辑或删除交付结果不明的消息')
queue.paused = false
delete queue.reason
return queue
})
notify(sessionId)
return snapshot(result)
},
async clear(sessionId: string): Promise<PromptQueueSnapshot> {
return locked(async () => {
const state = await load()
const existing = state.queues.find(queue => queue.sessionId === sessionId)
if (!existing) return snapshot(empty(sessionId))
if (existing.items.some(item => item.status === 'delivering')) throw new Error('存在正在交付的消息,不能清空队列')
state.queues = state.queues.filter(queue => queue.sessionId !== sessionId)
await saveStore(ports.file, state)
overlays.delete(queueKey(ports.file, sessionId))
lastKnownQueues.delete(queueKey(ports.file, sessionId))
const cleared = { ...empty(sessionId), revision: existing.revision + 1 }
try { ports.changed(snapshot(cleared)) } catch { /* Window lifetime does not affect persistence. */ }
return snapshot(cleared)
})
},
pause: (sessionId: string, reason: string): Promise<void> => mutate(sessionId, queue => {
queue.paused = true
queue.reason = reason
return queue
}).then(() => undefined),
async sendNow(sessionId: string, id: string): Promise<PromptQueueSnapshot> {
if (!ports.deliverNow) throw new Error('当前内核尚未接入追加结果归属,暂不能立即发送')
const reserved = await mutate(sessionId, async queue => {
const found = item(queue, id)
if (found.status !== 'pending') throw new Error('交付结果不明的消息不能立即重试')
await ports.validate(found.payload)
found.status = 'delivering'
delete found.error
found.deliveryOwner = { pid: process.pid, nonce: PROCESS_NONCE, hostname: LOCAL_HOST, startedAt: Date.now() }
return clone(found)
})
try {
await ports.deliverNow(reserved.payload, reserved.id)
} catch (error) {
if ((error as { submitted?: boolean })?.submitted === false) {
await mutate(sessionId, queue => {
const found = queue.items.find(value => value.id === id)
if (found) { found.status = 'pending'; found.error = errorMessage(error); delete found.deliveryOwner }
queue.paused = true; queue.reason = '立即发送未提交,请确认后恢复队列'
})
} else await setUnknown(sessionId, id, '引导消息交付未确认,已暂停自动发送', errorMessage(error))
throw error
}
try {
const result = await mutate(sessionId, queue => { queue.items = queue.items.filter(value => value.id !== id); return queue })
notify(sessionId)
return snapshot(result)
} catch (error) {
await setUnknown(sessionId, id, '消息已进入历史,但队列清理失败,请核对后删除', errorMessage(error))
throw error
}
},
notify
}
}
+7 -7
View File
@@ -94,7 +94,7 @@ export async function listSessions(): Promise<SessionMeta[]> {
return [...store.sessions]
// 置顶任务优先(最近置顶在前),其余按更新时间倒序。
.sort((a, b) => Number(b.pinnedAt ?? 0) - Number(a.pinnedAt ?? 0) || b.updatedAt - a.updatedAt)
.map(({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }) => ({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }))
.map(({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }) => ({ id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }))
}
/** 侧栏可见任务:默认排除已归档;`includeArchived` 供归档视图使用。 */
@@ -124,8 +124,8 @@ export async function setSessionArchived(sessionId: string, archived: boolean):
}
function meta(session: SessionRecord): SessionMeta {
const { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt } = session
return { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }
const { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt } = session
return { id, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, permissionMode, claudeSessionId, claudeForkAt, pinnedAt, archivedAt }
}
export async function createSession(title = '新对话', context: SessionContext = {}): Promise<SessionMeta> {
@@ -141,7 +141,7 @@ export async function createSession(title = '新对话', context: SessionContext
export async function getSessionContext(id: string): Promise<SessionContext> {
const session = (await load()).sessions.find((item) => item.id === id)
if (!session) throw new Error('会话不存在')
return { workspaceId: session.workspaceId, projectId: session.projectId, cwd: session.cwd, modelId: session.modelId, expertId: session.expertId, agentMode: agentMode(session.agentMode), thinkingLevel: session.thinkingLevel }
return { permissionMode: session.permissionMode, workspaceId: session.workspaceId, projectId: session.projectId, cwd: session.cwd, modelId: session.modelId, expertId: session.expertId, agentMode: agentMode(session.agentMode), thinkingLevel: session.thinkingLevel }
}
export async function updateSessionContext(id: string, patch: SessionContext): Promise<SessionMeta> {
const session = (await load()).sessions.find((item) => item.id === id)
@@ -152,8 +152,8 @@ export async function updateSessionContext(id: string, patch: SessionContext): P
for (const key of Object.keys(session)) if (!Object.hasOwn(previous, key)) delete (session as unknown as Record<string, unknown>)[key]
Object.assign(session, previous); throw error
}
const { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level } = session
return { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level }
const { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: mode, thinkingLevel: level, permissionMode } = session
return { id: sessionId, title, createdAt, updatedAt, workspaceId, projectId, cwd, modelId, expertId, agentMode: agentMode(mode), thinkingLevel: level, permissionMode }
}
export async function getMessages(sessionId: string): Promise<ChatMessage[]> {
@@ -266,4 +266,4 @@ export async function startTurn(sessionId: string, user: ChatMessage, assistant:
export function deriveTitle(text: string): string {
const flat = text.replace(/\s+/g, ' ').trim()
return flat.length > 24 ? `${flat.slice(0, 24)}…` : flat || '新对话'
}
}
+4
View File
@@ -176,3 +176,7 @@ export function rebindTerminal(owner: BrowserWindow, id: string, previousSession
export function disposeTerminalHosts(owner?: BrowserWindow): void {
for (const entry of terminals.values()) if (!owner || entry.owner === owner) remove(entry)
}
export function activeTerminalCount(): number {
return [...terminals.values()].filter(entry => !entry.view.exited).length
}
+59
View File
@@ -0,0 +1,59 @@
export interface UpdateLifecyclePorts {
quiesce(): Promise<void>
restore(): void
shutdown(): Promise<void>
exit(): void
log(error: unknown): void
prepareTimeoutMs?: number
quitTimeoutMs?: number
}
export function createUpdateLifecycle(ports: UpdateLifecyclePorts): {
beforeInstall(): Promise<boolean>
installFailed(): void
quitForUpdate(): Promise<void>
} {
let preparation: Promise<void> | null = null
let committed = false
const restore = (): void => { if (!committed) ports.restore() }
const installFailed = (): void => {
// Stopping work is not cancellable. Do not resume underneath an old stop.
if (preparation) void preparation.then(restore, restore)
else restore()
}
return {
async beforeInstall() {
if (committed) throw new Error('安装器已启动,请退出应用完成更新')
if (preparation) throw new Error('上次更新准备仍在结束中,请稍后重试')
let timer: ReturnType<typeof setTimeout> | undefined
const pending = ports.quiesce()
preparation = pending
void pending.finally(() => { if (preparation === pending) preparation = null }).catch(() => undefined)
try {
await Promise.race([
pending,
new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error('任务清理尚未结束,暂不能开始新任务;请稍后重试或重启应用')), ports.prepareTimeoutMs ?? 30_000)
})
])
return true
} finally { clearTimeout(timer) }
},
installFailed,
async quitForUpdate() {
committed = true
let timer: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
ports.shutdown(),
new Promise<void>(resolve => { timer = setTimeout(resolve, ports.quitTimeoutMs ?? 15_000) })
])
} catch (error) {
ports.log(error)
} finally {
clearTimeout(timer)
ports.exit()
}
}
}
}
+21
View File
@@ -0,0 +1,21 @@
let paused = false
const pending = new Set<Promise<unknown>>()
export function assertRuntimeAvailable(): void {
if (paused) throw new Error('正在准备重启更新,请稍后重试')
}
export function withRuntimeStart<T>(operation: () => Promise<T>): Promise<T> {
assertRuntimeAvailable()
const task = Promise.resolve().then(operation)
pending.add(task)
void task.finally(() => pending.delete(task)).catch(() => undefined)
return task
}
export async function pauseRuntimeStarts(): Promise<void> {
paused = true
await Promise.allSettled([...pending])
}
export function resumeRuntimeStarts(): void { paused = false }
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { mkdir, rename, rm, stat } from 'node:fs/promises'
import { isAbsolute, relative, resolve } from 'node:path'
import { parseVersion } from './semver'
import { UpdateError, type CachedInstaller } from './types'
export function installerCachePaths(cacheDirectory: string, version: string): CachedInstaller {
parseVersion(version)
const filename = `YCode-${version}-Setup.exe`
const path = resolve(cacheDirectory, filename)
const relation = relative(resolve(cacheDirectory), path)
if (!relation || relation.startsWith('..') || isAbsolute(relation)) {
throw new UpdateError('unsafe-cache-path', '安装包缓存路径无效')
}
const partPath = `${path}.part`
return { version, path, partPath, size: 0, sha256: '' }
}
export async function sha256File(filename: string): Promise<string> {
const hash = createHash('sha256')
for await (const chunk of createReadStream(filename)) hash.update(chunk)
return hash.digest('hex')
}
export async function verifyInstallerFile(
installer: CachedInstaller,
expected: { size: number; sha256: string }
): Promise<void> {
let info
try {
info = await stat(installer.path)
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new UpdateError('cache-missing', '安装包尚未下载')
}
throw error
}
if (!info.isFile() || info.size !== expected.size) {
throw new UpdateError('cache-invalid', '安装包缓存大小不匹配')
}
if (await sha256File(installer.path) !== expected.sha256) {
throw new UpdateError('cache-invalid', '安装包缓存 SHA-256 不匹配')
}
}
export async function prepareCacheDirectory(cacheDirectory: string): Promise<void> {
await mkdir(cacheDirectory, { recursive: true })
}
export async function commitPartFile(installer: CachedInstaller): Promise<void> {
await rm(installer.path, { force: true })
await rename(installer.partPath, installer.path)
}
export async function removeCacheFiles(installer: CachedInstaller): Promise<void> {
await rm(installer.path, { force: true })
await rm(installer.partPath, { force: true })
}
export function assertInstallerInsideCache(cacheDirectory: string, filename: string): void {
const relation = relative(resolve(cacheDirectory), resolve(filename))
if (!relation || relation.startsWith('..') || isAbsolute(relation)) {
throw new UpdateError('unsafe-cache-path', '安装包路径不在更新缓存目录中')
}
}
+91
View File
@@ -0,0 +1,91 @@
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { app, BrowserWindow, ipcMain, shell } from 'electron'
import { UpdateManager } from './manager'
import { createNsisInstaller } from './installer'
import { registerUpdateIpc } from './ipc'
import { physicalRealPath, physicalStat } from './physical-files'
import { FileUpdatePersistence } from './storage'
import { UPDATE_MANIFEST_URL } from './types'
export interface RegisterUpdatesOptions {
beforeInstall: () => Promise<boolean>
quitForUpdate: () => Promise<void>
installFailed?: () => void | Promise<void>
}
export interface RegisteredUpdates {
start(): void
dispose(): void
}
function publicKeyPath(): string {
return app.isPackaged
? join(process.resourcesPath, 'updates', 'public-key.pem')
: join(app.getAppPath(), 'resources', 'updates', 'public-key.pem')
}
async function readPublicKey(): Promise<string> {
const value = await readFile(publicKeyPath(), 'utf8')
if (value.length > 64 * 1024) throw new Error('更新公钥文件过大')
return value
}
export function registerUpdates(options: RegisterUpdatesOptions): RegisteredUpdates {
const userData = app.getPath('userData')
const cacheDirectory = join(userData, 'updates', 'cache')
const logFile = join(userData, 'updates', 'updater.log')
const supported = app.isPackaged && process.platform === 'win32' && process.arch === 'x64'
const installer = createNsisInstaller({
platform: process.platform,
isPackaged: app.isPackaged,
execPath: process.execPath,
appPath: app.getAppPath(),
cacheDirectory,
resolveRealPath: physicalRealPath,
readStat: physicalStat
})
const manager = new UpdateManager({
currentVersion: app.getVersion(),
manifestUrl: UPDATE_MANIFEST_URL,
cacheDirectory,
logFile,
persistence: new FileUpdatePersistence(join(userData, 'updates', 'state.json')),
publicKey: readPublicKey,
supported,
installer,
beforeInstall: options.beforeInstall,
quitForUpdate: options.quitForUpdate,
installFailed: options.installFailed
})
const ipc = registerUpdateIpc({
manager,
ipcMain,
shell,
getAllWindows: () => BrowserWindow.getAllWindows(),
appPath: app.getAppPath(),
rendererUrl: process.env.ELECTRON_RENDERER_URL,
platform: process.platform
})
let started = false
let disposed = false
return {
start: () => {
if (started || disposed) return
started = true
manager.start()
},
dispose: () => {
if (disposed) return
disposed = true
manager.dispose()
ipc.dispose()
}
}
}
export { UpdateManager } from './manager'
export { isTrustedUpdateRendererUrl } from './trust'
export { UPDATE_MANIFEST_URL } from './types'
+205
View File
@@ -0,0 +1,205 @@
import { execFile, spawn } from 'node:child_process'
import { realpath, stat, statfs } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { basename, dirname, parse, resolve } from 'node:path'
import { promisify } from 'node:util'
import { assertInstallerInsideCache, verifyInstallerFile } from './cache'
import { UpdateError, type CachedInstaller, type InstallEnvironment, type InstallerAdapter } from './types'
const execFileAsync = promisify(execFile)
// Current YCode NSIS uninstall registration under HKCU.
export const NSIS_UNINSTALL_KEY = 'Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\8a4ee941-d893-569b-8bcb-2a55d270c04b'
export interface SpawnedInstallerProcess {
once(event: 'spawn', listener: () => void): this
once(event: 'error', listener: (error: Error) => void): this
unref(): void
}
export type SpawnInstaller = (
command: string,
args: readonly string[],
options: { cwd: string; detached: boolean; windowsHide: boolean; stdio: 'ignore' }
) => SpawnedInstallerProcess
export interface NsisInstallerOptions {
platform?: NodeJS.Platform
isPackaged: boolean
execPath: string
appPath: string
cacheDirectory: string
processId?: number
spawn?: SpawnInstaller
queryRegistryValue?: (name: string) => Promise<string | null>
verifyFile?: (installer: CachedInstaller) => Promise<void>
resolveRealPath?: (value: string) => Promise<string>
readStat?: (value: string) => Promise<{ isFile(): boolean }>
readDiskStats?: (value: string) => Promise<{ bsize: number | bigint; bavail: number | bigint }>
}
function parseRegistryValue(output: string, name: string): string | null {
const expression = new RegExp(`^\\s*${name}\\s+REG_\\w+\\s+(.+?)\\s*$`, 'mi')
const match = expression.exec(output)
if (!match) return null
return match[1].replace(/^"(.*)"$/, '$1')
}
export async function queryNsisRegistryValue(name: string): Promise<string | null> {
if (process.platform !== 'win32') return null
try {
const { stdout } = await execFileAsync(
'reg.exe',
['query', `HKCU\\${NSIS_UNINSTALL_KEY}`, '/v', name],
{ encoding: 'utf8', windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }
)
return parseRegistryValue(stdout, name)
} catch (error) {
const code = (error as { code?: unknown }).code
if (code === 1 || code === '1') return null
throw new UpdateError('installer-registry', '无法读取 NSIS 安装注册信息')
}
}
function normalizedWindowsPath(value: string): string {
return resolve(value).replace(/[\\/]+$/, '').toLowerCase()
}
function registryExecutablePath(value: string): string | null {
const quoted = /^\s*"([^"]+\.exe)"/i.exec(value)
if (quoted) return quoted[1]
const plain = /^\s*(.+?\.exe)(?:\s|$|,)/i.exec(value)
return plain ? plain[1] : null
}
export function createNsisInstaller(options: NsisInstallerOptions): InstallerAdapter {
const platform = options.platform ?? process.platform
const queryRegistryValue = options.queryRegistryValue ?? queryNsisRegistryValue
const spawnInstaller: SpawnInstaller = options.spawn ?? ((command, args, spawnOptions) => spawn(
command,
[...args],
spawnOptions
))
const verifyFile = options.verifyFile
?? ((installer: CachedInstaller) => verifyInstallerFile(installer, {
size: installer.size,
sha256: installer.sha256
}))
const resolveRealPath = options.resolveRealPath ?? realpath
const readStat = options.readStat ?? stat
const readDiskStats = options.readDiskStats ?? statfs
async function validateEnvironment(requiredBytes = 0): Promise<InstallEnvironment> {
if (platform !== 'win32') throw new UpdateError('unsupported', '自动更新仅支持 Windows')
if (!options.isPackaged) throw new UpdateError('development-build', '开发模式禁止启动安装器')
let executablePath: string
let appPath: string
try {
executablePath = await resolveRealPath(options.execPath)
appPath = await resolveRealPath(options.appPath)
} catch {
throw new UpdateError('installer-environment', '无法解析当前安装位置')
}
if (basename(executablePath).toLowerCase() !== 'ycode.exe') {
throw new UpdateError('installer-environment', '当前进程不是 YCode 安装版')
}
const installationDirectory = dirname(executablePath)
const root = parse(installationDirectory).root
if (normalizedWindowsPath(installationDirectory) === normalizedWindowsPath(root)) {
throw new UpdateError('installer-environment', '安装目录不安全')
}
if (resolve(appPath) !== resolve(installationDirectory, 'resources', 'app.asar')) {
throw new UpdateError('installer-environment', '应用资源目录与安装位置不一致')
}
const uninstallString = await queryRegistryValue('UninstallString')
const uninstallerPath = uninstallString ? registryExecutablePath(uninstallString) : null
if (!uninstallerPath
|| normalizedWindowsPath(dirname(uninstallerPath)) !== normalizedWindowsPath(installationDirectory)
|| basename(uninstallerPath).toLowerCase() !== 'uninstall ycode.exe') {
throw new UpdateError('installer-registry', '当前用户 NSIS 卸载注册信息不匹配')
}
const displayIcon = await queryRegistryValue('DisplayIcon')
const iconPath = displayIcon ? registryExecutablePath(displayIcon.replace(/,\s*-?\d+\s*$/, '')) : null
if (!iconPath
|| normalizedWindowsPath(dirname(iconPath)) !== normalizedWindowsPath(installationDirectory)
|| normalizedWindowsPath(iconPath) !== normalizedWindowsPath(executablePath)) {
throw new UpdateError('installer-registry', '当前用户 YCode 图标注册信息不匹配')
}
for (const marker of [
uninstallerPath,
executablePath,
resolve(installationDirectory, 'resources', 'app.asar')
]) {
let info
try {
info = await readStat(marker)
} catch {
throw new UpdateError('installer-marker', '缺少 NSIS 安装标记文件')
}
if (!info.isFile()) {
throw new UpdateError('installer-marker', `NSIS 安装标记不是文件:${basename(marker)}`)
}
}
if (requiredBytes > 0) {
const required = BigInt(Math.ceil(requiredBytes)) * 3n
for (const directory of new Set([options.cacheDirectory, installationDirectory, tmpdir()])) {
let disk
try {
disk = await readDiskStats(directory)
} catch {
throw new UpdateError('installer-space-check', '无法检查更新所需磁盘空间')
}
const available = BigInt(disk.bsize) * BigInt(disk.bavail)
if (available < required) {
throw new UpdateError('installer-space-check', '更新缓存、安装目录或临时目录磁盘空间不足')
}
}
}
return {
installationDirectory,
executablePath,
registryKey: `HKCU\\${NSIS_UNINSTALL_KEY}`
}
}
async function verifyInstaller(installer: CachedInstaller): Promise<void> {
assertInstallerInsideCache(options.cacheDirectory, installer.path)
await verifyFile(installer)
}
async function launch(installer: CachedInstaller, environment: InstallEnvironment): Promise<void> {
await verifyInstaller(installer)
const args = [
'/S',
'--updated',
'--force-run',
'/currentuser',
`/YCODEWAITPID=${options.processId ?? process.pid}`,
`/D=${environment.installationDirectory}`
]
let child: SpawnedInstallerProcess
try {
child = spawnInstaller(installer.path, args, {
cwd: environment.installationDirectory,
detached: true,
windowsHide: true,
stdio: 'ignore'
})
} catch {
throw new UpdateError('installer-launch', '无法启动更新安装器')
}
await new Promise<void>((resolveSpawn, rejectSpawn) => {
child.once('spawn', resolveSpawn)
child.once('error', rejectSpawn)
}).catch(() => {
throw new UpdateError('installer-launch', '无法启动更新安装器')
})
child.unref()
}
return { validateEnvironment, verifyInstaller, launch }
}
+103
View File
@@ -0,0 +1,103 @@
import { BrowserWindow, type IpcMainInvokeEvent, type Shell } from 'electron'
import type { UpdateState } from '../../shared/updates'
import { releasePageUrl } from './manifest'
import type { UpdateManager } from './manager'
import { isTrustedUpdateRendererUrl, type TrustedRendererOptions } from './trust'
const HANDLER_CHANNELS = [
'updates:get-state',
'updates:check',
'updates:download',
'updates:install',
'updates:dismiss',
'updates:open-download-page',
'updates:open-log'
] as const
function isTrustedSender(event: IpcMainInvokeEvent, options: TrustedRendererOptions): boolean {
if (event.senderFrame !== event.sender.mainFrame || event.sender.isDestroyed()) return false
const window = BrowserWindow.fromWebContents(event.sender)
if (!window || window.isDestroyed() || window.webContents !== event.sender) return false
return isTrustedUpdateRendererUrl(event.sender.getURL(), options)
}
export interface RegisterUpdateIpcOptions extends TrustedRendererOptions {
manager: UpdateManager
ipcMain: Pick<Electron.IpcMain, 'handle' | 'removeHandler'>
shell: Pick<Shell, 'openExternal' | 'openPath'>
getAllWindows: () => BrowserWindow[]
}
function handle(
options: RegisterUpdateIpcOptions,
channel: typeof HANDLER_CHANNELS[number],
callback: (...args: unknown[]) => Promise<unknown> | unknown
): void {
options.ipcMain.handle(channel, async (event, ...args) => {
if (!isTrustedSender(event, options)) throw new Error('更新请求来源不受信任')
return callback(...args)
})
}
function safeExternalUrl(value: string): string {
const url = new URL(value)
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
throw new Error('更新页面地址不受信任')
}
return url.href
}
export function registerUpdateIpc(options: RegisterUpdateIpcOptions): { dispose(): void } {
for (const channel of HANDLER_CHANNELS) {
switch (channel) {
case 'updates:get-state':
handle(options, channel, () => options.manager.getState())
break
case 'updates:check':
handle(options, channel, () => options.manager.checkAndDownload(true))
break
case 'updates:download':
handle(options, channel, () => options.manager.download())
break
case 'updates:install':
handle(options, channel, () => options.manager.install())
break
case 'updates:dismiss':
handle(options, channel, () => options.manager.dismiss())
break
case 'updates:open-download-page':
handle(options, channel, async () => {
await options.shell.openExternal(safeExternalUrl(releasePageUrl(options.manager.manifestUrl)))
})
break
case 'updates:open-log':
handle(options, channel, async () => {
await options.manager.ensureLogFile()
const failure = await options.shell.openPath(options.manager.logPath)
if (failure) throw new Error(`无法打开更新日志:${failure}`)
})
break
}
}
const unsubscribe = options.manager.subscribe((state: UpdateState) => {
for (const window of options.getAllWindows()) {
try {
if (window.isDestroyed() || !isTrustedUpdateRendererUrl(window.webContents.getURL(), options)) continue
window.webContents.send('updates:state', state)
} catch {
// A window can close while the updater is broadcasting.
}
}
})
return {
dispose: () => {
unsubscribe()
for (const channel of HANDLER_CHANNELS) options.ipcMain.removeHandler(channel)
}
}
}
export { isTrustedUpdateRendererUrl }
+53
View File
@@ -0,0 +1,53 @@
import { appendFile, mkdir } from 'node:fs/promises'
import { dirname } from 'node:path'
function sanitizeUrl(value: string): string {
try {
const url = new URL(value)
url.username = ''
url.password = ''
url.search = ''
url.hash = ''
return url.toString()
} catch {
return '<invalid-url>'
}
}
export function sanitizeLogText(value: string): string {
return value
.replace(/https?:\/\/[^\s"'<>]+/gi, match => sanitizeUrl(match))
.replace(/\b(authorization|password|secret|token)\s*[:=]\s*[^\s,;]+/gi, '$1=<redacted>')
.slice(0, 16_384)
}
export class UpdateLogger {
private queue: Promise<void> = Promise.resolve()
constructor(readonly filePath: string) {}
info(message: string): void {
this.write('INFO', message)
}
error(message: string, error?: unknown): void {
const detail = error instanceof Error ? error.message : error === undefined ? '' : String(error)
this.write('ERROR', detail ? `${message}: ${detail}` : message)
}
async ensureFile(): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true })
await appendFile(this.filePath, '', { encoding: 'utf8', mode: 0o600 })
}
private write(level: string, message: string): void {
const line = `${new Date().toISOString()} ${level} ${sanitizeLogText(message)}\n`
this.queue = this.queue
.catch(() => undefined)
.then(async () => {
await mkdir(dirname(this.filePath), { recursive: true })
await appendFile(this.filePath, line, { encoding: 'utf8', mode: 0o600 })
})
.catch(() => undefined)
}
}
+852
View File
@@ -0,0 +1,852 @@
import { open, rm, stat } from 'node:fs/promises'
import type { UpdateState } from '../../shared/updates'
import {
commitPartFile,
installerCachePaths,
prepareCacheDirectory,
removeCacheFiles,
sha256File,
verifyInstallerFile
} from './cache'
import { UpdateLogger } from './logger'
import { normalizeManifestUrl, verifySignedManifest } from './manifest'
import {
createRequestDeadline,
fetchManifestBytes,
fetchSameOrigin,
parseContentRange,
responseContentLength
} from './network'
import { compareVersions, parseVersion } from './semver'
import {
DEFAULT_CHECK_DELAY_MS,
DEFAULT_CHECK_INTERVAL_MS,
DEFAULT_DOWNLOAD_TIMEOUT_MS,
DEFAULT_MANIFEST_TIMEOUT_MS,
DEFAULT_UP_TO_DATE_RESET_MS,
StaleReleaseError,
UpdateCancelledError,
UpdateError,
type CachedInstaller,
type FetchLike,
type InstallEnvironment,
type InstallerAdapter,
type PersistedUpdateState,
type ReleaseInfo,
type StateListener,
type UpdatePersistence
} from './types'
export interface UpdateManagerOptions {
currentVersion: string
manifestUrl: string
cacheDirectory: string
logFile: string
persistence: UpdatePersistence
publicKey: () => Promise<string>
supported: boolean
installer: InstallerAdapter
beforeInstall: () => Promise<boolean>
quitForUpdate: () => Promise<void>
installFailed?: () => void | Promise<void>
fetch?: FetchLike
now?: () => Date
checkDelayMs?: number
checkIntervalMs?: number
upToDateResetMs?: number
manifestTimeoutMs?: number
downloadTimeoutMs?: number
autoDownload?: boolean
}
function emptyPersisted(): PersistedUpdateState {
return { schemaVersion: 1 }
}
function cloneState(state: UpdateState): UpdateState {
return { ...state }
}
function errorMessage(error: unknown): string {
if (error instanceof UpdateError && error.message) return error.message
if (error instanceof Error && error.message) return error.message
return '更新操作失败'
}
function identity(release: ReleaseInfo): string {
return [
release.payload.version,
release.asset.url,
release.asset.size,
release.asset.sha256
].join('\n')
}
export class UpdateManager {
private readonly logger: UpdateLogger
private readonly fetchImpl: FetchLike
private readonly now: () => Date
private persisted: PersistedUpdateState = emptyPersisted()
private state: UpdateState
private release: ReleaseInfo | null = null
private generation = 0
private initialized: Promise<void> | null = null
private checkPromise: Promise<UpdateState> | null = null
private downloadPromise: Promise<UpdateState> | null = null
private installPromise: Promise<UpdateState> | null = null
private downloadAbort: AbortController | null = null
private readonly disposedAbort = new AbortController()
private disposed = false
private started = false
private explicitRequested = false
private installCommitted = false
private checkDelayTimer: ReturnType<typeof setTimeout> | null = null
private checkIntervalTimer: ReturnType<typeof setInterval> | null = null
private upToDateTimer: ReturnType<typeof setTimeout> | null = null
private saveQueue: Promise<void> = Promise.resolve()
private readonly listeners = new Set<StateListener>()
constructor(private readonly options: UpdateManagerOptions) {
this.logger = new UpdateLogger(options.logFile)
this.fetchImpl = options.fetch ?? fetch
this.now = options.now ?? (() => new Date())
this.state = {
status: options.supported ? 'idle' : 'unsupported',
currentVersion: options.currentVersion
}
}
subscribe(listener: StateListener): () => void {
this.listeners.add(listener)
return () => this.listeners.delete(listener)
}
get manifestUrl(): string {
return this.options.manifestUrl
}
get logPath(): string {
return this.options.logFile
}
async ensureLogFile(): Promise<void> {
await this.logger.ensureFile()
}
async getState(): Promise<UpdateState> {
await this.ensureInitialized()
return cloneState(this.state)
}
start(): void {
if (this.started || this.disposed || !this.options.supported) return
this.started = true
void this.ensureInitialized().catch(error => this.logger.error('初始化更新模块失败', error))
this.checkDelayTimer = setTimeout(() => {
this.checkDelayTimer = null
void this.checkAndDownload(false).catch(error => this.logger.error('后台检查更新失败', error))
}, this.options.checkDelayMs ?? DEFAULT_CHECK_DELAY_MS)
this.checkDelayTimer.unref?.()
this.checkIntervalTimer = setInterval(() => {
void this.checkAndDownload(false).catch(error => this.logger.error('后台检查更新失败', error))
}, this.options.checkIntervalMs ?? DEFAULT_CHECK_INTERVAL_MS)
this.checkIntervalTimer.unref?.()
}
dispose(): void {
if (this.disposed) return
this.disposed = true
this.started = false
if (this.checkDelayTimer) clearTimeout(this.checkDelayTimer)
if (this.checkIntervalTimer) clearInterval(this.checkIntervalTimer)
if (this.upToDateTimer) clearTimeout(this.upToDateTimer)
this.checkDelayTimer = null
this.checkIntervalTimer = null
this.upToDateTimer = null
this.disposedAbort.abort()
this.downloadAbort?.abort()
this.listeners.clear()
}
async check(explicit = true): Promise<UpdateState> {
await this.ensureInitialized()
if (this.disposed || this.installCommitted) return cloneState(this.state)
if (this.installPromise) return cloneState(this.state)
if (this.checkPromise) {
if (explicit) this.setExplicit(true)
return this.checkPromise
}
if (!this.downloadPromise || explicit) this.setExplicit(explicit)
if (!this.options.supported) return cloneState(this.state)
const task = this.runCheck()
this.checkPromise = task
try {
return await task
} finally {
if (this.checkPromise === task) this.checkPromise = null
}
}
async checkAndDownload(explicit = true): Promise<UpdateState> {
const checked = await this.check(explicit)
if (!this.options.supported || this.options.autoDownload === false || this.disposed) return checked
if (checked.status !== 'available' || !checked.version) return checked
if (this.persisted.dismissedVersion === checked.version) return checked
void this.beginDownload(explicit).catch(error => this.logger.error('自动下载更新失败', error))
return cloneState(this.state)
}
async download(explicit = true): Promise<UpdateState> {
await this.ensureInitialized()
if (this.disposed || this.installCommitted) return cloneState(this.state)
this.setExplicit(explicit)
if (!this.options.supported) return cloneState(this.state)
if (this.installPromise) return cloneState(this.state)
return this.beginDownload(explicit)
}
private beginDownload(explicit: boolean): Promise<UpdateState> {
if (this.installCommitted) return Promise.resolve(cloneState(this.state))
this.setExplicit(explicit)
if (this.downloadPromise) return this.downloadPromise
const task = (async (): Promise<UpdateState> => {
if (!this.release) {
const checked = await this.check(explicit)
if (!this.release || checked.status === 'unsupported' || checked.status === 'up-to-date'
|| checked.status === 'error') return checked
}
const release = this.release
if (!release) return cloneState(this.state)
return this.runDownload(release)
})()
this.downloadPromise = task
return task.finally(() => {
if (this.downloadPromise === task) this.downloadPromise = null
})
}
async install(): Promise<UpdateState> {
await this.ensureInitialized()
if (this.disposed || this.installCommitted) return cloneState(this.state)
this.setExplicit(true)
if (!this.options.supported) return cloneState(this.state)
if (this.installPromise) return this.installPromise
const task = this.runInstall()
this.installPromise = task
try {
return await task
} finally {
if (this.installPromise === task) this.installPromise = null
}
}
async dismiss(): Promise<UpdateState> {
await this.ensureInitialized()
if (this.disposed || this.installCommitted || this.installPromise || this.state.status === 'installing') {
return cloneState(this.state)
}
this.setExplicit(true)
const version = this.state.version ?? this.release?.payload.version
if (!version) return cloneState(this.state)
this.downloadAbort?.abort()
this.persisted = { ...this.persisted, dismissedVersion: version }
await this.queuePersist(this.persisted).catch(error => this.logger.error('保存忽略版本失败', error))
this.setState({
status: 'idle',
currentVersion: this.options.currentVersion,
version,
notes: this.state.notes,
lastChecked: this.state.lastChecked,
dismissedVersion: version,
unfinishedVersion: this.persisted.unfinishedVersion
})
return cloneState(this.state)
}
private async ensureInitialized(): Promise<void> {
if (!this.initialized) this.initialized = this.initialize()
return this.initialized
}
private async initialize(): Promise<void> {
let persisted = emptyPersisted()
try {
persisted = await this.options.persistence.load()
} catch (error) {
this.logger.error('读取更新状态失败,已使用默认状态', error)
}
this.persisted = persisted
if (!this.options.supported) {
this.setState({ status: 'unsupported', currentVersion: this.options.currentVersion })
return
}
try {
parseVersion(this.options.currentVersion)
} catch (error) {
this.logger.error('当前应用版本无效', error)
this.setState({
status: 'error',
currentVersion: this.options.currentVersion,
error: '当前应用版本号无效'
})
return
}
let changed = false
if (persisted.dismissedVersion) {
try {
if (compareVersions(persisted.dismissedVersion, this.options.currentVersion) <= 0) {
persisted = { ...persisted, dismissedVersion: undefined }
changed = true
}
} catch {
persisted = { ...persisted, dismissedVersion: undefined }
changed = true
}
}
if (persisted.target) {
try {
if (compareVersions(persisted.target.version, this.options.currentVersion) <= 0) {
const { target: _target, unfinishedVersion: _unfinished, ...rest } = persisted
persisted = rest
changed = true
}
} catch {
const { target: _target, unfinishedVersion: _unfinished, ...rest } = persisted
persisted = rest
changed = true
}
}
this.persisted = persisted
if (changed) await this.queuePersist(persisted).catch(error => this.logger.error('清理过期更新状态失败', error))
this.setState({
status: 'idle',
currentVersion: this.options.currentVersion,
dismissedVersion: persisted.dismissedVersion,
unfinishedVersion: persisted.unfinishedVersion
})
}
private async runCheck(): Promise<UpdateState> {
this.clearUpToDateTimer()
const preservedState = this.downloadPromise || this.state.status === 'ready'
? cloneState(this.state)
: null
if (!preservedState) {
this.setState({
status: 'checking',
currentVersion: this.options.currentVersion,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion,
lastChecked: this.state.lastChecked
})
}
try {
const publicKeyPem = await this.options.publicKey()
const bytes = await fetchManifestBytes(this.options.manifestUrl, {
fetch: this.fetchImpl,
signal: this.disposedAbort.signal,
timeout: this.options.manifestTimeoutMs ?? DEFAULT_MANIFEST_TIMEOUT_MS
})
const verified = verifySignedManifest(bytes, {
manifestUrl: this.options.manifestUrl,
publicKeyPem
})
const downloadingSameRelease = Boolean(
this.downloadPromise
&& this.release
&& identity(this.release) === identity({ ...verified, generation: this.release.generation })
)
if (downloadingSameRelease && this.release) {
this.release = { ...verified, generation: this.release.generation }
const lastChecked = this.now().toISOString()
this.setState({
...this.state,
lastChecked
})
return cloneState(this.state)
}
if (this.downloadPromise) this.downloadAbort?.abort()
this.generation += 1
this.release = { ...verified, generation: this.generation }
const lastChecked = this.now().toISOString()
const comparison = compareVersions(verified.payload.version, this.options.currentVersion)
if (comparison <= 0) {
this.setState({
status: 'up-to-date',
currentVersion: this.options.currentVersion,
lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
this.scheduleUpToDateReset()
this.logger.info(`检查完成,当前已是最新版本 ${this.options.currentVersion}`)
return cloneState(this.state)
}
const cached = this.cachedInstaller(this.release)
let ready = false
try {
await verifyInstallerFile(cached, this.release.asset)
ready = true
} catch (error) {
if (error instanceof UpdateError && error.code === 'cache-invalid') {
await removeCacheFiles(cached).catch(() => undefined)
} else if (!(error instanceof UpdateError && error.code === 'cache-missing')) {
this.logger.error('校验已有更新缓存失败', error)
}
}
if (!this.release || cached.sha256 !== this.release.asset.sha256) return cloneState(this.state)
this.setState({
status: ready ? 'ready' : 'available',
currentVersion: this.options.currentVersion,
version: this.release.payload.version,
notes: this.release.payload.notes.zh || this.release.payload.notes.en,
...(ready ? { progress: 100 } : {}),
lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
this.logger.info(`发现更新 ${this.release.payload.version}`)
return cloneState(this.state)
} catch (error) {
if (this.disposed || error instanceof UpdateCancelledError) return cloneState(this.state)
const usableState = this.state.status === 'ready' || this.state.status === 'downloading'
? this.state
: preservedState
if (usableState) {
this.logger.error('检查更新失败,保留已校验安装包或当前下载', error)
this.setState({ ...usableState, lastChecked: this.now().toISOString() })
return cloneState(this.state)
}
const message = errorMessage(error)
this.logger.error('检查更新失败', error)
this.setState({
status: 'error',
currentVersion: this.options.currentVersion,
error: message,
lastChecked: this.now().toISOString(),
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
return cloneState(this.state)
}
}
private async runDownload(release: ReleaseInfo): Promise<UpdateState> {
const cached = this.cachedInstaller(release)
this.assertCurrentRelease(release)
this.clearUpToDateTimer()
this.setState({
status: 'downloading',
currentVersion: this.options.currentVersion,
version: release.payload.version,
notes: release.payload.notes.zh || release.payload.notes.en,
progress: 0,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
const abort = new AbortController()
this.downloadAbort = abort
let handle: Awaited<ReturnType<typeof open>> | null = null
try {
await prepareCacheDirectory(this.options.cacheDirectory)
try {
await verifyInstallerFile(cached, release.asset)
this.assertCurrentRelease(release)
this.setState({ ...this.state, status: 'ready', progress: 100 })
return cloneState(this.state)
} catch (error) {
if (error instanceof UpdateError && error.code === 'cache-invalid') {
await rm(cached.path, { force: true })
} else if (!(error instanceof UpdateError && error.code === 'cache-missing')) {
throw error
}
}
let start = 0
try {
const info = await stat(cached.partPath)
if (!info.isFile()) await rm(cached.partPath, { force: true })
else start = info.size
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
}
if (start > release.asset.size) {
await rm(cached.partPath, { force: true })
start = 0
}
if (start === release.asset.size) {
if (await sha256File(cached.partPath) === release.asset.sha256) {
await commitPartFile(cached)
this.assertCurrentRelease(release)
this.setState({ ...this.state, status: 'ready', progress: 100 })
return cloneState(this.state)
}
await rm(cached.partPath, { force: true })
start = 0
}
if (start > 0) this.setState({ ...this.state, progress: Math.floor(start / release.asset.size * 100) })
const deadline = createRequestDeadline(
abort.signal,
this.options.downloadTimeoutMs ?? DEFAULT_DOWNLOAD_TIMEOUT_MS
)
try {
const { response } = await fetchSameOrigin(release.asset.url, {
method: 'GET',
headers: {
accept: 'application/octet-stream',
...(start > 0 ? { range: `bytes=${start}-` } : {})
},
signal: deadline.signal,
cache: 'no-store'
}, {
fetch: this.fetchImpl,
allowedOrigin: new URL(this.options.manifestUrl).origin
})
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('download-http', `安装包下载失败(HTTP ${response.status})`)
}
let writeStart = start
if (start > 0 && response.status === 200) writeStart = 0
if (start > 0 && response.status !== 206 && response.status !== 200) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('range-unsupported', '服务器未返回可续传的响应')
}
let expectedSegment: number
if (writeStart > 0) {
const range = parseContentRange(response.headers.get('content-range'))
if (!range || range.start !== writeStart || range.total !== release.asset.size
|| range.end !== release.asset.size - 1) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-content-range', '断点续传 Content-Range 无效')
}
expectedSegment = range.end - range.start + 1
const declared = responseContentLength(response)
if (declared !== undefined && declared !== expectedSegment) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-content-length', '断点续传长度无效')
}
} else {
if (response.status === 206) {
const range = parseContentRange(response.headers.get('content-range'))
if (!range || range.start !== 0 || range.total !== release.asset.size || range.end !== release.asset.size - 1) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-content-range', '安装包响应范围无效')
}
}
expectedSegment = release.asset.size
const declared = responseContentLength(response)
if (declared !== undefined && declared !== expectedSegment) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-content-length', '安装包响应长度无效')
}
}
if (!response.body) throw new UpdateError('empty-response', '安装包响应没有正文')
handle = await open(cached.partPath, writeStart > 0 ? 'a' : 'w', 0o600)
let written = writeStart
let lastProgress = this.state.progress ?? 0
for await (const raw of response.body as unknown as AsyncIterable<Uint8Array>) {
this.assertCurrentRelease(release)
if (abort.signal.aborted || this.disposed) throw new UpdateCancelledError()
const chunk = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
if (written + chunk.byteLength > release.asset.size) {
throw new UpdateError('download-too-large', '安装包正文超过签名大小')
}
await handle.write(chunk)
written += chunk.byteLength
const progress = Math.min(99, Math.floor(written / release.asset.size * 100))
if (progress !== lastProgress) {
lastProgress = progress
this.setState({ ...this.state, progress })
}
}
await handle.sync()
await handle.close()
handle = null
if (written !== release.asset.size) throw new UpdateError('download-incomplete', '安装包下载不完整')
if (deadline.timedOut()) throw new UpdateError('download-timeout', '安装包下载超时')
this.assertCurrentRelease(release)
const digest = await sha256File(cached.partPath)
if (digest !== release.asset.sha256) {
await rm(cached.partPath, { force: true })
throw new UpdateError('download-checksum', '安装包 SHA-256 校验失败')
}
await commitPartFile(cached)
this.assertCurrentRelease(release)
await verifyInstallerFile(cached, release.asset)
this.setState({
status: 'ready',
currentVersion: this.options.currentVersion,
version: release.payload.version,
notes: release.payload.notes.zh || release.payload.notes.en,
progress: 100,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
this.logger.info(`安装包下载并校验完成 ${release.payload.version}`)
return cloneState(this.state)
} catch (error) {
if (deadline.timedOut()) throw new UpdateError('download-timeout', '安装包下载超时')
throw error
} finally {
deadline.dispose()
}
} catch (error) {
if (handle) await handle.close().catch(() => undefined)
const releaseChanged = !this.release || this.release.generation !== release.generation
if (abort.signal.aborted || this.disposed || error instanceof StaleReleaseError || releaseChanged) {
if (releaseChanged) await rm(cached.partPath, { force: true }).catch(() => undefined)
return cloneState(this.state)
}
if (error instanceof UpdateCancelledError) {
if (this.state.status === 'downloading') {
this.setState({ ...this.state, status: 'available', progress: undefined })
}
return cloneState(this.state)
}
const detail = (error instanceof UpdateError && error.code === 'download-timeout')
? '安装包下载超时'
: errorMessage(error)
this.logger.error('下载更新失败', error)
this.setState({
status: 'error',
currentVersion: this.options.currentVersion,
version: release.payload.version,
notes: release.payload.notes.zh || release.payload.notes.en,
error: detail,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
return cloneState(this.state)
} finally {
if (this.downloadAbort === abort) this.downloadAbort = null
}
}
private async runInstall(): Promise<UpdateState> {
if (!this.release && (this.state.status === 'available' || this.state.status === 'idle')) {
await this.check()
}
if (!this.release) return cloneState(this.state)
if (this.state.status === 'available') {
const downloaded = await this.beginDownload(true)
if (downloaded.status !== 'ready') return downloaded
}
if (this.state.status !== 'ready') return cloneState(this.state)
const release = this.release
const cached = this.cachedInstaller(release)
let environment: InstallEnvironment
try {
this.assertCurrentRelease(release)
await verifyInstallerFile(cached, release.asset)
environment = await this.options.installer.validateEnvironment(release.asset.size)
} catch (error) {
const message = errorMessage(error)
this.logger.error('安装前校验失败', error)
this.setState({
...this.state,
status: 'error',
error: message
})
return cloneState(this.state)
}
let accepted: boolean
try {
accepted = await this.options.beforeInstall() === true
} catch (error) {
this.logger.error('安装确认或运行任务清理失败', error)
await this.notifyInstallFailed()
this.setState({
...this.state,
status: 'error',
error: errorMessage(error)
})
return cloneState(this.state)
}
if (!accepted) return cloneState(this.state)
try {
this.assertCurrentRelease(release)
environment = await this.options.installer.validateEnvironment(release.asset.size)
await this.options.installer.verifyInstaller(cached)
this.assertCurrentRelease(release)
} catch (error) {
this.logger.error('安装确认后复核失败', error)
await this.notifyInstallFailed()
this.setState({
...this.state,
status: 'error',
error: errorMessage(error)
})
return cloneState(this.state)
}
this.setState({
status: 'installing',
currentVersion: this.options.currentVersion,
version: release.payload.version,
notes: release.payload.notes.zh || release.payload.notes.en,
progress: 100,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: release.payload.version
})
this.persisted = {
...this.persisted,
unfinishedVersion: release.payload.version,
target: {
version: release.payload.version,
size: release.asset.size,
sha256: release.asset.sha256,
manifestUrl: normalizeManifestUrl(this.options.manifestUrl)
}
}
try {
await this.queuePersist(this.persisted)
} catch (error) {
this.logger.error('写入更新安装标记失败', error)
await this.clearUnfinished()
await this.notifyInstallFailed()
this.setState({
...this.state,
status: 'error',
error: '无法写入更新安装标记'
})
return cloneState(this.state)
}
try {
await this.options.installer.launch(cached, environment)
} catch (error) {
this.logger.error('启动更新安装器失败', error)
await this.clearUnfinished()
await this.notifyInstallFailed()
this.setState({
...this.state,
status: 'error',
error: errorMessage(error)
})
return cloneState(this.state)
}
this.installCommitted = true
try {
await this.options.quitForUpdate()
} catch (error) {
this.logger.error('退出应用以安装更新失败', error)
this.setState({
...this.state,
status: 'installing',
error: '更新安装器已启动,必须退出 YCode 才能继续安装'
})
return cloneState(this.state)
}
return cloneState(this.state)
}
private cachedInstaller(release: ReleaseInfo): CachedInstaller {
const paths = installerCachePaths(this.options.cacheDirectory, release.payload.version)
return {
...paths,
size: release.asset.size,
sha256: release.asset.sha256
}
}
private assertCurrentRelease(release: ReleaseInfo): void {
if (!this.release || this.release.generation !== release.generation || identity(this.release) !== identity(release)) {
throw new StaleReleaseError()
}
}
private setState(state: UpdateState): void {
if (this.disposed) return
this.state = cloneState({ ...state, lastExplicit: this.explicitRequested })
for (const listener of this.listeners) {
try {
listener(cloneState(this.state))
} catch {
// A renderer subscriber must not break updater state transitions.
}
}
}
private setExplicit(explicit: boolean): void {
if (this.explicitRequested === explicit) return
this.explicitRequested = explicit
this.setState({ ...this.state, lastExplicit: explicit })
}
private scheduleUpToDateReset(): void {
this.clearUpToDateTimer()
this.upToDateTimer = setTimeout(() => {
this.upToDateTimer = null
if (this.state.status === 'up-to-date' && !this.downloadPromise && !this.installPromise) {
this.setState({
status: 'idle',
currentVersion: this.options.currentVersion,
lastChecked: this.state.lastChecked,
dismissedVersion: this.persisted.dismissedVersion,
unfinishedVersion: this.persisted.unfinishedVersion
})
}
}, this.options.upToDateResetMs ?? DEFAULT_UP_TO_DATE_RESET_MS)
this.upToDateTimer.unref?.()
}
private clearUpToDateTimer(): void {
if (this.upToDateTimer) clearTimeout(this.upToDateTimer)
this.upToDateTimer = null
}
private queuePersist(state: PersistedUpdateState): Promise<void> {
const snapshot: PersistedUpdateState = {
schemaVersion: 1,
...(state.dismissedVersion ? { dismissedVersion: state.dismissedVersion } : {}),
...(state.unfinishedVersion ? { unfinishedVersion: state.unfinishedVersion } : {}),
...(state.target ? { target: { ...state.target } } : {})
}
const task = this.saveQueue
.catch(() => undefined)
.then(() => this.options.persistence.save(snapshot))
this.saveQueue = task
return task
}
private async clearUnfinished(): Promise<void> {
const { target: _target, unfinishedVersion: _unfinished, ...rest } = this.persisted
this.persisted = rest
this.setState({ ...this.state, unfinishedVersion: undefined })
await this.queuePersist(this.persisted).catch(error => this.logger.error('清除更新安装标记失败', error))
}
private async notifyInstallFailed(): Promise<void> {
if (!this.options.installFailed) return
try {
await this.options.installFailed()
} catch (error) {
this.logger.error('恢复更新失败后的后台服务失败', error)
}
}
}
+194
View File
@@ -0,0 +1,194 @@
import { createHash, createPublicKey, verify } from 'node:crypto'
import { posix } from 'node:path'
import { parseVersion } from './semver'
import {
MAX_INSTALLER_BYTES,
MAX_MANIFEST_BYTES,
UPDATE_PLATFORM,
UpdateError,
type ReleaseInfo,
type UpdateManifestPayload,
type UpdatePlatformAsset
} from './types'
const SHA256_RE = /^[a-f0-9]{64}$/
const SIGNATURE_RE = /^[A-Za-z0-9+/]{86}==$/
const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/
function exactKeys(value: unknown, expected: string[], label: string): asserts value is Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new UpdateError('invalid-manifest', `${label} 必须是对象`)
}
const actual = Object.keys(value)
if (actual.length !== expected.length || !expected.every(key => actual.includes(key))) {
throw new UpdateError('invalid-manifest', `${label} 包含缺失或未知字段`)
}
}
export function normalizeManifestUrl(value: string): string {
let url: URL
try {
url = new URL(value)
} catch {
throw new UpdateError('invalid-manifest-url', '更新清单地址无效')
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new UpdateError('invalid-manifest-url', '更新清单只支持 HTTP 或 HTTPS')
}
if (url.username || url.password || url.search || url.hash) {
throw new UpdateError('invalid-manifest-url', '更新清单地址不能包含凭据、查询参数或片段')
}
if (!url.pathname.endsWith('/latest.json')) {
throw new UpdateError('invalid-manifest-url', '更新清单地址必须以 latest.json 结尾')
}
return url.href
}
export function releasePageUrl(manifestUrl: string): string {
const normalized = normalizeManifestUrl(manifestUrl)
return new URL('./', normalized).href
}
function assertHttpArtifactUrl(value: unknown): string {
if (typeof value !== 'string' || value.length === 0 || value.length > 4096) {
throw new UpdateError('invalid-manifest', '安装包 URL 无效')
}
let url: URL
try {
url = new URL(value)
} catch {
throw new UpdateError('invalid-manifest', '安装包 URL 无效')
}
if ((url.protocol !== 'http:' && url.protocol !== 'https:')
|| url.username || url.password || url.search || url.hash) {
throw new UpdateError('invalid-manifest', '安装包 URL 不安全')
}
return url.href
}
function parsePublicKey(publicKeyPem: string) {
if (typeof publicKeyPem !== 'string' || publicKeyPem.length === 0 || publicKeyPem.length > 64 * 1024
|| publicKeyPem.includes('PRIVATE KEY')) {
throw new UpdateError('invalid-public-key', '更新公钥无效')
}
let key
try {
key = createPublicKey(publicKeyPem)
} catch {
throw new UpdateError('invalid-public-key', '无法解析更新公钥')
}
if (key.asymmetricKeyType !== 'ed25519') {
throw new UpdateError('invalid-public-key', '更新公钥必须是 Ed25519')
}
return key
}
function validatePayload(
payload: Record<string, unknown>,
manifestUrl: string
): { payload: UpdateManifestPayload; asset: UpdatePlatformAsset } {
exactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], '更新清单 payload')
if (payload.schemaVersion !== 1) throw new UpdateError('invalid-manifest', '更新清单 schemaVersion 不受支持')
if (typeof payload.version !== 'string') throw new UpdateError('invalid-manifest', '更新清单版本字段无效')
parseVersion(payload.version)
if (typeof payload.publishedAt !== 'string') throw new UpdateError('invalid-manifest', 'publishedAt 必须是字符串')
const published = new Date(payload.publishedAt)
if (Number.isNaN(published.valueOf()) || published.toISOString() !== payload.publishedAt) {
throw new UpdateError('invalid-manifest', 'publishedAt 必须是规范 UTC ISO 时间')
}
exactKeys(payload.notes, ['zh', 'en'], '更新说明')
const notes = payload.notes as Record<string, unknown>
for (const language of ['zh', 'en']) {
const text = notes[language]
if (typeof text !== 'string' || text.trim().length === 0 || text.length > 16_384) {
throw new UpdateError('invalid-manifest', `更新说明 ${language} 无效`)
}
}
exactKeys(payload.platforms, [UPDATE_PLATFORM], '更新平台列表')
const platforms = payload.platforms as Record<string, unknown>
const rawAsset = platforms[UPDATE_PLATFORM]
exactKeys(rawAsset, ['url', 'size', 'sha256'], 'windows-x64 安装包')
const asset = rawAsset as Record<string, unknown>
if (!Number.isSafeInteger(asset.size) || (asset.size as number) <= 0 || (asset.size as number) > MAX_INSTALLER_BYTES) {
throw new UpdateError('invalid-manifest', '安装包大小无效')
}
if (typeof asset.sha256 !== 'string' || !SHA256_RE.test(asset.sha256)) {
throw new UpdateError('invalid-manifest', '安装包 SHA-256 无效')
}
const artifactUrl = assertHttpArtifactUrl(asset.url)
const parsedUrl = new URL(artifactUrl)
const filename = posix.basename(parsedUrl.pathname)
if (!EXE_NAME_RE.test(filename)) throw new UpdateError('invalid-manifest', '安装包文件名不受支持')
const baseUrl = new URL('./', normalizeManifestUrl(manifestUrl))
const expected = new URL(`versions/${payload.version}/${filename}`, baseUrl).href
if (artifactUrl !== expected) {
throw new UpdateError('invalid-manifest', '安装包 URL 不在受控版本目录中')
}
return {
payload: payload as unknown as UpdateManifestPayload,
asset: {
url: artifactUrl,
size: asset.size as number,
sha256: asset.sha256
}
}
}
export function verifySignedManifest(
raw: Uint8Array,
options: { manifestUrl: string; publicKeyPem: string }
): ReleaseInfo {
if (raw.byteLength === 0 || raw.byteLength > MAX_MANIFEST_BYTES) {
throw new UpdateError('invalid-manifest', '更新清单大小无效')
}
const manifestUrl = normalizeManifestUrl(options.manifestUrl)
let text: string
let document: unknown
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(raw)
document = JSON.parse(text)
} catch {
throw new UpdateError('invalid-manifest', '更新清单不是有效 UTF-8 JSON')
}
exactKeys(document, ['payload', 'signature'], '更新清单')
const root = document as { payload: unknown; signature: unknown }
if (!root.payload || typeof root.payload !== 'object' || Array.isArray(root.payload)) {
throw new UpdateError('invalid-manifest', '更新清单 payload 必须是对象')
}
if (typeof root.signature !== 'string' || !SIGNATURE_RE.test(root.signature)) {
throw new UpdateError('invalid-manifest', '更新清单签名格式无效')
}
const signature = Buffer.from(root.signature, 'base64')
if (signature.length !== 64 || signature.toString('base64') !== root.signature) {
throw new UpdateError('invalid-manifest', '更新清单签名必须使用规范 Base64')
}
const publicKey = parsePublicKey(options.publicKeyPem)
const canonicalPayload = Buffer.from(JSON.stringify(root.payload), 'utf8')
let valid = false
try {
valid = verify(null, canonicalPayload, publicKey, signature)
} catch {
valid = false
}
if (!valid) throw new UpdateError('invalid-signature', '更新清单签名验证失败')
const validated = validatePayload(root.payload as Record<string, unknown>, manifestUrl)
const fingerprint = createHash('sha256')
.update(publicKey.export({ type: 'spki', format: 'der' }))
.digest('hex')
return {
manifestUrl,
payload: validated.payload,
asset: validated.asset,
signatureFingerprint: fingerprint,
generation: 0
}
}
+164
View File
@@ -0,0 +1,164 @@
import {
MAX_MANIFEST_BYTES,
MAX_REDIRECTS,
UpdateCancelledError,
UpdateError,
type FetchLike
} from './types'
export interface RequestDeadline {
signal: AbortSignal
timedOut(): boolean
dispose(): void
}
export function createRequestDeadline(parent: AbortSignal | undefined, timeoutMs: number): RequestDeadline {
const controller = new AbortController()
let timedOut = false
const abortFromParent = (): void => controller.abort(parent?.reason)
if (parent?.aborted) controller.abort(parent.reason)
else parent?.addEventListener('abort', abortFromParent, { once: true })
const timer = setTimeout(() => {
timedOut = true
controller.abort(new Error('request timeout'))
}, timeoutMs)
timer.unref?.()
return {
signal: controller.signal,
timedOut: () => timedOut,
dispose: () => {
clearTimeout(timer)
parent?.removeEventListener('abort', abortFromParent)
}
}
}
function checkedUrl(value: string | URL, label: string): URL {
let url: URL
try {
url = new URL(value)
} catch {
throw new UpdateError('invalid-url', `${label}无效`)
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new UpdateError('invalid-url', `${label}只支持 HTTP 或 HTTPS`)
}
if (url.username || url.password) throw new UpdateError('invalid-url', `${label}不能包含凭据`)
url.hash = ''
return url
}
export async function fetchSameOrigin(
input: string | URL,
init: RequestInit,
options: { fetch: FetchLike; allowedOrigin?: string }
): Promise<{ response: Response; url: URL }> {
let current = checkedUrl(input, '请求地址')
const allowedOrigin = options.allowedOrigin ?? current.origin
if (current.origin !== allowedOrigin) throw new UpdateError('cross-origin', '请求地址不在更新服务器源内')
for (let redirectCount = 0; redirectCount <= MAX_REDIRECTS; redirectCount += 1) {
const response = await options.fetch(current.href, {
...init,
redirect: 'manual'
})
if (response.url) {
const effective = checkedUrl(response.url, '响应地址')
if (effective.origin !== allowedOrigin) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('cross-origin', '更新服务器返回了跨源响应')
}
}
if (![301, 302, 303, 307, 308].includes(response.status)) return { response, url: current }
if (redirectCount === MAX_REDIRECTS) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('too-many-redirects', '更新请求重定向次数过多')
}
const location = response.headers.get('location')
if (!location) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('invalid-redirect', '更新请求缺少重定向地址')
}
await response.body?.cancel().catch(() => undefined)
const next = checkedUrl(new URL(location, current).href, '重定向地址')
if (next.origin !== allowedOrigin) throw new UpdateError('cross-origin', '禁止跨源重定向')
current = next
}
throw new UpdateError('too-many-redirects', '更新请求重定向次数过多')
}
function contentLength(response: Response): number | undefined {
const value = response.headers.get('content-length')
if (value === null) return undefined
if (!/^\d+$/.test(value)) throw new UpdateError('invalid-response', '响应 Content-Length 无效')
const parsed = Number(value)
if (!Number.isSafeInteger(parsed)) throw new UpdateError('invalid-response', '响应 Content-Length 无效')
return parsed
}
export async function readBoundedResponse(response: Response, maximum: number): Promise<Uint8Array> {
const declared = contentLength(response)
if (declared !== undefined && declared > maximum) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('response-too-large', '更新响应超过大小限制')
}
if (!response.body) throw new UpdateError('empty-response', '更新响应没有正文')
const chunks: Uint8Array[] = []
let total = 0
for await (const raw of response.body as unknown as AsyncIterable<Uint8Array>) {
const chunk = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
total += chunk.byteLength
if (total > maximum) throw new UpdateError('response-too-large', '更新响应超过大小限制')
chunks.push(chunk)
}
const result = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.byteLength
}
return result
}
export async function fetchManifestBytes(
manifestUrl: string,
options: { fetch: FetchLike; signal: AbortSignal; timeout: number }
): Promise<Uint8Array> {
const deadline = createRequestDeadline(options.signal, options.timeout)
try {
const { response } = await fetchSameOrigin(manifestUrl, {
method: 'GET',
headers: { accept: 'application/json' },
signal: deadline.signal,
cache: 'no-store'
}, { fetch: options.fetch })
if (!response.ok) {
await response.body?.cancel().catch(() => undefined)
throw new UpdateError('manifest-http', `更新清单请求失败(HTTP ${response.status})`)
}
return await readBoundedResponse(response, MAX_MANIFEST_BYTES)
} catch (error) {
if (options.signal.aborted) throw new UpdateCancelledError()
if (deadline.timedOut()) throw new UpdateError('manifest-timeout', '检查更新超时')
throw error
} finally {
deadline.dispose()
}
}
export function parseContentRange(value: string | null): { start: number; end: number; total: number } | null {
if (!value) return null
const match = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(value)
if (!match) return null
const start = Number(match[1])
const end = Number(match[2])
const total = Number(match[3])
if (![start, end, total].every(Number.isSafeInteger) || start > end || end >= total) return null
return { start, end, total }
}
export function responseContentLength(response: Response): number | undefined {
return contentLength(response)
}
export { checkedUrl }
+14
View File
@@ -0,0 +1,14 @@
import { createRequire } from 'node:module'
import type * as NodeFs from 'node:fs'
// Electron's patched fs exposes app.asar as a virtual directory. Installer
// identity checks must observe the physical archive instead.
const originalFs = createRequire(process.execPath)('node:original-fs') as typeof NodeFs
export async function physicalRealPath(value: string): Promise<string> {
return originalFs.promises.realpath(value)
}
export async function physicalStat(value: string): Promise<NodeFs.Stats> {
return originalFs.promises.stat(value)
}
+64
View File
@@ -0,0 +1,64 @@
import { UpdateError } from './types'
const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/
export interface ParsedVersion {
major: bigint
minor: bigint
patch: bigint
prerelease: string[]
build: string[]
}
export function parseVersion(value: string): ParsedVersion {
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
throw new UpdateError('invalid-version', '版本号格式无效')
}
const match = SEMVER_RE.exec(value)
if (!match) throw new UpdateError('invalid-version', '版本号不是严格的 SemVer')
const prerelease = match[4] ? match[4].split('.') : []
for (const identifier of prerelease) {
if (/^\d+$/.test(identifier) && identifier.length > 1 && identifier.startsWith('0')) {
throw new UpdateError('invalid-version', 'SemVer 预发布数字不能包含前导零')
}
}
return {
major: BigInt(match[1]),
minor: BigInt(match[2]),
patch: BigInt(match[3]),
prerelease,
build: match[5] ? match[5].split('.') : []
}
}
function compareIdentifier(left: string, right: string): number {
const leftNumeric = /^\d+$/.test(left)
const rightNumeric = /^\d+$/.test(right)
if (leftNumeric && rightNumeric) {
const a = BigInt(left)
const b = BigInt(right)
return a < b ? -1 : a > b ? 1 : 0
}
if (leftNumeric) return -1
if (rightNumeric) return 1
return left < right ? -1 : left > right ? 1 : 0
}
export function compareVersions(left: string, right: string): number {
const a = parseVersion(left)
const b = parseVersion(right)
for (const key of ['major', 'minor', 'patch'] as const) {
if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1
}
if (a.prerelease.length === 0 && b.prerelease.length === 0) return 0
if (a.prerelease.length === 0) return 1
if (b.prerelease.length === 0) return -1
const length = Math.max(a.prerelease.length, b.prerelease.length)
for (let index = 0; index < length; index += 1) {
if (a.prerelease[index] === undefined) return -1
if (b.prerelease[index] === undefined) return 1
const compared = compareIdentifier(a.prerelease[index], b.prerelease[index])
if (compared !== 0) return compared
}
return 0
}
+84
View File
@@ -0,0 +1,84 @@
import { randomUUID } from 'node:crypto'
import { mkdir, open, readFile, rename, rm, stat } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { PersistedUpdateState, UpdatePersistence } from './types'
const MAX_STATE_BYTES = 64 * 1024
function emptyState(): PersistedUpdateState {
return { schemaVersion: 1 }
}
function validOptionalVersion(value: unknown): value is string | undefined {
return value === undefined || typeof value === 'string'
}
function validateState(value: unknown): PersistedUpdateState {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('更新状态文件格式无效')
const candidate = value as Partial<PersistedUpdateState>
if (candidate.schemaVersion !== 1) throw new Error('更新状态文件版本不受支持')
if (!validOptionalVersion(candidate.dismissedVersion) || !validOptionalVersion(candidate.unfinishedVersion)) {
throw new Error('更新状态文件版本字段无效')
}
const target = candidate.target
if (target !== undefined) {
if (!target || typeof target !== 'object' || Array.isArray(target)
|| typeof target.version !== 'string'
|| !Number.isSafeInteger(target.size) || target.size <= 0
|| typeof target.sha256 !== 'string'
|| typeof target.manifestUrl !== 'string') {
throw new Error('更新目标记录无效')
}
}
return {
schemaVersion: 1,
...(candidate.dismissedVersion ? { dismissedVersion: candidate.dismissedVersion } : {}),
...(candidate.unfinishedVersion ? { unfinishedVersion: candidate.unfinishedVersion } : {}),
...(target ? {
target: {
version: target.version,
size: target.size,
sha256: target.sha256,
manifestUrl: target.manifestUrl
}
} : {})
}
}
export class FileUpdatePersistence implements UpdatePersistence {
constructor(readonly filePath: string) {}
async load(): Promise<PersistedUpdateState> {
let size: number
try {
const info = await stat(this.filePath)
if (!info.isFile()) throw new Error('更新状态路径不是文件')
size = info.size
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return emptyState()
throw error
}
if (size > MAX_STATE_BYTES) throw new Error('更新状态文件过大')
return validateState(JSON.parse(await readFile(this.filePath, 'utf8')))
}
async save(state: PersistedUpdateState): Promise<void> {
const validated = validateState(state)
const directory = dirname(this.filePath)
const temporary = join(directory, `.${randomUUID()}.tmp`)
await mkdir(directory, { recursive: true })
const handle = await open(temporary, 'wx', 0o600)
try {
await handle.writeFile(`${JSON.stringify(validated, null, 2)}\n`, 'utf8')
await handle.sync()
} finally {
await handle.close()
}
try {
await rename(temporary, this.filePath)
} catch (error) {
await rm(temporary, { force: true })
throw error
}
}
}
+53
View File
@@ -0,0 +1,53 @@
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
export interface TrustedRendererOptions {
appPath: string
rendererUrl?: string
platform?: NodeJS.Platform
}
function comparablePath(value: string, platform: NodeJS.Platform): string {
const resolved = resolve(value)
return platform === 'win32' ? resolved.toLowerCase() : resolved
}
export function isTrustedUpdateRendererUrl(raw: string, options: TrustedRendererOptions): boolean {
let url: URL
try {
url = new URL(raw)
} catch {
return false
}
if (url.username || url.password) return false
if (url.searchParams.get('window') === 'task') return false
if (url.protocol === 'file:') {
let pathname: string
try {
pathname = fileURLToPath(url)
} catch {
return false
}
const platform = options.platform ?? process.platform
const candidate = comparablePath(pathname, platform)
return [
resolve(options.appPath, 'out', 'renderer', 'index.html'),
resolve(options.appPath, 'out', 'renderer', 'settings.html')
].some(allowed => comparablePath(allowed, platform) === candidate)
}
if ((url.protocol === 'http:' || url.protocol === 'https:') && options.rendererUrl) {
let renderer: URL
try {
renderer = new URL(options.rendererUrl)
} catch {
return false
}
if (renderer.username || renderer.password || renderer.origin !== url.origin) return false
return url.pathname === '/'
|| url.pathname === '/index.html'
|| url.pathname === '/settings.html'
}
return false
}
+110
View File
@@ -0,0 +1,110 @@
import type { UpdateState } from '../../shared/updates'
export const UPDATE_MANIFEST_URL = 'http://192.168.10.6/ycode/releases/latest.json'
export const UPDATE_PLATFORM = 'windows-x64' as const
export const MAX_MANIFEST_BYTES = 1024 * 1024
export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024
export const MAX_REDIRECTS = 5
export const DEFAULT_CHECK_DELAY_MS = 10_000
export const DEFAULT_CHECK_INTERVAL_MS = 5 * 60_000
export const DEFAULT_UP_TO_DATE_RESET_MS = 3_000
export const DEFAULT_MANIFEST_TIMEOUT_MS = 20_000
export const DEFAULT_DOWNLOAD_TIMEOUT_MS = 10 * 60_000
export interface UpdateNotes {
zh: string
en: string
}
export interface UpdatePlatformAsset {
url: string
size: number
sha256: string
}
export interface UpdateManifestPayload {
schemaVersion: 1
version: string
publishedAt: string
notes: UpdateNotes
platforms: {
'windows-x64': UpdatePlatformAsset
}
}
export interface SignedUpdateManifest {
payload: UpdateManifestPayload
signature: string
}
export interface ReleaseInfo {
manifestUrl: string
payload: UpdateManifestPayload
asset: UpdatePlatformAsset
signatureFingerprint: string
generation: number
}
export interface CachedInstaller {
version: string
path: string
partPath: string
size: number
sha256: string
}
export interface InstallEnvironment {
installationDirectory: string
executablePath: string
registryKey: string
}
export interface InstallerAdapter {
validateEnvironment(requiredBytes?: number): Promise<InstallEnvironment>
verifyInstaller(installer: CachedInstaller): Promise<void>
launch(installer: CachedInstaller, environment: InstallEnvironment): Promise<void>
}
export interface PersistedUpdateState {
schemaVersion: 1
dismissedVersion?: string
unfinishedVersion?: string
target?: {
version: string
size: number
sha256: string
manifestUrl: string
}
}
export interface UpdatePersistence {
load(): Promise<PersistedUpdateState>
save(state: PersistedUpdateState): Promise<void>
}
export type FetchLike = (input: string | URL, init?: RequestInit) => Promise<Response>
export type StateListener = (state: UpdateState) => void
export class UpdateError extends Error {
readonly code: string
constructor(code: string, message: string) {
super(message)
this.name = 'UpdateError'
this.code = code
}
}
export class StaleReleaseError extends UpdateError {
constructor() {
super('stale-release', '更新版本已变化')
this.name = 'StaleReleaseError'
}
}
export class UpdateCancelledError extends UpdateError {
constructor() {
super('cancelled', '更新操作已取消')
this.name = 'UpdateCancelledError'
}
}
+43
View File
@@ -10,6 +10,20 @@ import type { DownloadPayload, ResourceRange } from '@shared/resources'
import type { InteractionResponse } from '@shared/conversation'
const api: YCodeApi = {
updates: {
getState: () => ipcRenderer.invoke('updates:get-state'),
check: () => ipcRenderer.invoke('updates:check'),
download: () => ipcRenderer.invoke('updates:download'),
install: () => ipcRenderer.invoke('updates:install'),
dismiss: () => ipcRenderer.invoke('updates:dismiss'),
openDownloadPage: () => ipcRenderer.invoke('updates:open-download-page'),
openLog: () => ipcRenderer.invoke('updates:open-log'),
onState: (listener) => {
const handler = (_event: unknown, state: Parameters<typeof listener>[0]): void => listener(state)
ipcRenderer.on('updates:state', handler)
return () => ipcRenderer.removeListener('updates:state', handler)
}
},
projects: {
list: () => ipcRenderer.invoke('projects:list'),
create: (input) => ipcRenderer.invoke('projects:create', input),
@@ -23,6 +37,20 @@ const api: YCodeApi = {
openPath: id => ipcRenderer.invoke('experts:open-path', id)
},
skills: {
recommendations: () => ipcRenderer.invoke('skills:recommendations'),
installRecommended: (id, options) => ipcRenderer.invoke('skills:install-recommended', id, options),
cancelInstall: (id) => ipcRenderer.invoke('skills:cancel-install', id),
installState: () => ipcRenderer.invoke('skills:install-state'),
onInstallProgress: (listener) => {
const handler = (_event: unknown, progress: Parameters<typeof listener>[0]): void => listener(progress)
ipcRenderer.on('skills:install-progress', handler)
return () => ipcRenderer.removeListener('skills:install-progress', handler)
},
onChanged: (listener) => {
const handler = (): void => listener()
ipcRenderer.on('skills:changed', handler)
return () => ipcRenderer.removeListener('skills:changed', handler)
},
list: (cwd) => ipcRenderer.invoke('skills:list', cwd),
importPath: (path, overwrite, kind) => ipcRenderer.invoke('skills:import', path, overwrite, kind),
read: (id, cwd) => ipcRenderer.invoke('skills:read', id, cwd),
@@ -216,6 +244,21 @@ const api: YCodeApi = {
openWindow: (sessionId: string) => ipcRenderer.invoke('session:open-window', sessionId)
},
chat: {
queue: {
get: id => ipcRenderer.invoke('chat:queue:get', id),
enqueue: payload => ipcRenderer.invoke('chat:queue:enqueue', payload),
take: (sessionId, id) => ipcRenderer.invoke('chat:queue:take', sessionId, id),
restoreEdit: (sessionId, id) => ipcRenderer.invoke('chat:queue:restore-edit', sessionId, id),
remove: (sessionId, id) => ipcRenderer.invoke('chat:queue:remove', sessionId, id),
reorder: (sessionId, ids) => ipcRenderer.invoke('chat:queue:reorder', sessionId, ids),
resume: id => ipcRenderer.invoke('chat:queue:resume', id),
sendNow: (sessionId, id) => ipcRenderer.invoke('chat:queue:send-now', sessionId, id),
onChanged: listener => {
const handler = (_event: Electron.IpcRendererEvent, snapshot: import('../shared/prompt-queue').PromptQueueSnapshot): void => listener(snapshot)
ipcRenderer.on('chat:queue:changed', handler)
return () => ipcRenderer.removeListener('chat:queue:changed', handler)
}
},
activeRun: (sessionId) => ipcRenderer.invoke('chat:active-run', sessionId),
setSessionVisible: (sessionId) => ipcRenderer.invoke('chat:set-session-visible', sessionId),
onStarted: (listener) => {
+6 -12
View File
@@ -64,14 +64,14 @@ export default function App(): React.JSX.Element {
const defaultWorkspace = useSettingsStore(state => state.workspace)
const capabilityCwd = sessions.find(session => session.id === currentSessionId)?.cwd ?? draftContext.cwd ?? defaultWorkspace
const useSkill = (skill: SkillRecord): void => {
setRoute('chat')
window.dispatchEvent(new CustomEvent('ycode:select-skill', { detail: { skill, owner: currentSessionId ?? `new-${draftId}` } }))
if (!skill.enabled || skill.error || !skill.userInvocable) return
creationIntent('', skill)
}
const creationIntent = (text: string, skill?: Pick<SkillRecord, 'id' | 'name'>): void => {
const state = useChatStore.getState()
const settings = useSettingsStore.getState()
const current = state.sessions.find(session => session.id === state.currentSessionId)
state.startTask(current ? { workspaceId: current.workspaceId, cwd: current.cwd, modelId: current.modelId ?? settings.selectedModelId ?? undefined } : { ...state.draftContext, expertId: null, ...(settings.selectedModelId ? { modelId: settings.selectedModelId } : {}) })
state.startTask(current ? { workspaceId: current.workspaceId, cwd: current.cwd, modelId: current.modelId ?? settings.selectedModelId ?? undefined, expertId: null } : { ...state.draftContext, expertId: null, modelId: state.draftContext.modelId ?? settings.selectedModelId ?? undefined })
setRoute('chat')
// The chat pane remains mounted. Publish after its new draft owner commits.
const owner = `new-${useChatStore.getState().draftId}`
@@ -330,13 +330,12 @@ export default function App(): React.JSX.Element {
// 全局快捷键(TODO 12):与「设置 → 快捷键」中已接入的行保持一致。
// Ctrl+, 设置;Ctrl+K 聚焦全局搜索;Ctrl+B / Ctrl+Shift+B 切换左/右侧栏;
// Ctrl+N 新建对话;生成中按 Esc 停止。其余键位只在该页面展示,未接入时不宣称生效。
// Escape belongs to menus, previews and dialogs, never generation cancellation.
const shortcutActions = useRef({
openSettings: (): void => {},
focusSearch: (): void => {},
toggleSidebar: (): void => {},
toggleRightSidebar: (): void => {},
stopRun: (): void => {}
})
shortcutActions.current = {
openSettings,
@@ -349,18 +348,13 @@ export default function App(): React.JSX.Element {
if (narrow.isNarrowForSidebar) setLeftDrawerOpen((value) => !value)
else setSidebarCollapsed((value) => !value)
},
toggleRightSidebar: () => setRightVisible((value) => !value),
stopRun: () => { if (useChatStore.getState().running) void useChatStore.getState().cancel() }
toggleRightSidebar: () => setRightVisible((value) => !value)
}
useEffect(() => {
const onKey = (event: KeyboardEvent): void => {
if (event.isComposing) return
if (document.querySelector('.yc-skill-modal-host')) return
const actions = shortcutActions.current
if (event.key === 'Escape' && !event.defaultPrevented) {
// 与 WorkBuddy 一致:Esc 只在生成中停止本次运行,不吞掉其他弹层的 Esc。
if (useChatStore.getState().running) { event.preventDefault(); actions.stopRun() }
return
}
if ((!event.ctrlKey && !event.metaKey) || event.altKey) return
const key = event.key.toLowerCase()
if (key === ',') { event.preventDefault(); actions.openSettings(); return }
@@ -66,7 +66,7 @@ export default function CapabilityCenter(props: CapabilityCenterProps): React.JS
</header>
<div className="capability-page-host" role="tabpanel" aria-label={{ experts: '专家', skills: '技能', connectors: '连接器' }[tab]}>
{tab === 'experts' && <ExpertsPage />}
{tab === 'skills' && <SkillsPage />}
{tab === 'skills' && <SkillsPage cwd={props.cwd} onUseSkill={props.onUseSkill} onCreateSkill={props.onCreateSkill} onFindSkill={props.onFindSkill} />}
{tab === 'connectors' && <ConnectorsPage />}
</div>
</div></div>
@@ -1,5 +1,101 @@
import CapabilityMaintenance from './CapabilityMaintenance'
import { useCallback, useEffect, useRef, useState, type ComponentProps, type ComponentType } from 'react'
import { createPortal } from 'react-dom'
import type { McpServerView } from '@shared/capabilities'
import { CapabilityToolbar } from './CapabilityCenter'
import { SearchIcon, ClearFilledIcon } from './SkillSourceIcons'
import { ChevronIcon, CloseIcon, ConnectorAddIcon, ConnectorEmptyIcon, RefreshIcon, CopyIcon, ConnectorEditIcon, McpAddIcon, McpHubIcon, McpCloseIcon, McpSearchIcon, McpClearIcon, SOURCE_BUDDY_ICON } from './ConnectorSourceIcons'
import { MonacoEditor, configureMonaco, themeManager } from '../workbuddy/workbench/source-code-assets/wrapper.mjs'
import { mcpEditorDocument, parseMcpEditor, mcpIconColor } from './connectors-helpers'
import '../workbuddy/workbench/sourceCode.css'
import './connectors-source.css'
import './connectors-adapters.css'
function Search({ value, onChange, placeholder, mcp = false }: { value: string; onChange(value: string): void; placeholder: string; mcp?: boolean }): React.JSX.Element {
if (mcp) return <div className="cb-search-input cb-search-input--small"><div className="cb-search-input__icon"><McpSearchIcon /></div><input className="cb-search-input__input" value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} aria-label={placeholder} /><button className={'cb-search-input__clear' + (value ? ' cb-search-input__clear--visible' : '')} aria-label="清除搜索" onClick={() => onChange('')}><McpClearIcon /></button></div>
return <span className="wb-input-wrapper wb-input-wrapper--medium yc-mcp-search"><span className="wb-input__prefix"><SearchIcon /></span><input className="wb-input__inner" value={value} onChange={event => onChange(event.target.value)} placeholder={placeholder} aria-label={placeholder} />{value && <button className="yc-mcp-search-clear" aria-label="清除搜索" onClick={() => onChange('')}><ClearFilledIcon /></button>}</span>
}
function SourceButton({ children, icon, onClick, disabled = false, primary = false }: { children: React.ReactNode; icon?: React.ReactNode; onClick(): void; disabled?: boolean; primary?: boolean }): React.JSX.Element {
return <button type="button" className={`wb-button wb-button--${primary ? 'primary' : 'secondary'} wb-button--medium`} onClick={onClick} disabled={disabled}>{icon && <span className="wb-button__icon wb-button__icon--left">{icon}</span>}<span className="wb-button__content">{children}</span></button>
}
function JsonEditor({ content, setContent }: { content: string; setContent(value: string): void }): React.JSX.Element {
const [ready, setReady] = useState(false), [failed, setFailed] = useState(false)
useEffect(() => { let active = true; void configureMonaco().then(() => { if (active) { themeManager().setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'); setReady(true) } }).catch(() => { if (active) setFailed(true) }); return () => { active = false } }, [])
// The recovered source wrapper already provides onChange; the preview-only declaration is narrower.
const EditableMonaco = MonacoEditor as ComponentType<ComponentProps<typeof MonacoEditor> & { onChange(value: string | undefined): void }>
return failed ? <textarea className="mcp-config-textarea" aria-label="MCP JSON 配置" spellCheck={false} value={content} onChange={event => setContent(event.target.value)} /> : !ready ? <div className="mcp-config-editor-loading">加载编辑器…</div> : <EditableMonaco value={content} onChange={value => setContent(value || '')} language="json" readOnly={false} minimap={false} lineNumbers="on" wordWrap="on" fontSize={13} height="100%" onMount={editor => {
editor.updateOptions({ readOnly: false, automaticLayout: true, scrollBeyondLastLine: false, padding: { top: 8, bottom: 8 } })
}} />
}
function ServerEditor({ server, onSaved, onBack }: { server?: McpServerView; onSaved(): Promise<void>; onBack(): void }): React.JSX.Element {
const initial = useRef(mcpEditorDocument(server)), [content, setContent] = useState(initial.current), [saving, setSaving] = useState(false), [error, setError] = useState('')
const dirty = content !== initial.current
const back = (): void => { if (!saving && (!dirty || window.confirm('配置尚未保存,确定返回吗?'))) onBack() }
const save = async (): Promise<void> => {
if (saving) return
setError('')
let input
try { input = parseMcpEditor(content, server) } catch (reason) { setError((reason as Error).message); return }
setSaving(true)
try { await window.ycode.mcp.save(input); await onSaved(); onBack() }
catch (reason) { setError((reason as Error).message || '保存配置失败') }
finally { setSaving(false) }
}
return <div className="mcp-config-editor"><div className="mcp-config-editor-header"><button className="mcp-config-editor-back" onClick={back} disabled={saving}>‹ 返回列表</button><div className="mcp-config-editor-header-actions"><SourceButton onClick={back} disabled={saving}>取消</SourceButton><SourceButton onClick={() => void save()} primary disabled={saving || !dirty}>{saving ? '保存中…' : '保存'}</SourceButton></div></div>
<div className="mcp-config-editor-path"><span className="mcp-config-editor-path-label">{server ? server.name : '添加 MCP'}</span>{dirty && <span className="mcp-config-editor-unsaved">未保存</span>}</div>
<p className="yc-mcp-editor-note">每次配置一个 MCP 服务。已有环境变量和 Headers 留空保持不变;保存后需批准变更的配置。</p>{error && <div className="yc-mcp-error" role="alert">{error}</div>}
<div className="mcp-config-editor-body"><div className="mcp-config-editor-monaco-wrapper"><JsonEditor content={content} setContent={setContent} /></div></div>
</div>
}
export default function ConnectorsPage(): React.JSX.Element {
return <CapabilityMaintenance title="连接器" />
const [servers, setServers] = useState<McpServerView[]>([]), [query, setQuery] = useState(''), [listQuery, setListQuery] = useState('')
const [loading, setLoading] = useState(true), [error, setError] = useState(''), [modal, setModal] = useState(false), [warningDismissed, setWarningDismissed] = useState(false)
const [editor, setEditor] = useState<{ server?: McpServerView } | null>(null), [detail, setDetail] = useState<string | null>(null), [confirmRemove, setConfirmRemove] = useState<McpServerView | null>(null)
const [expanded, setExpanded] = useState<Set<string>>(new Set()), [busy, setBusy] = useState<Set<string>>(new Set())
const busyRef = useRef(new Set<string>()), alive = useRef(true), opener = useRef<HTMLButtonElement>(null), refreshGeneration = useRef(0)
const refresh = useCallback(async (): Promise<void> => {
const generation = ++refreshGeneration.current
const items = await window.ycode.mcp.list()
if (alive.current && generation === refreshGeneration.current) setServers(items)
}, [])
useEffect(() => { alive.current = true; void refresh().catch(reason => setError(reason.message || '加载 MCP 配置失败')).finally(() => setLoading(false)); return () => { alive.current = false } }, [refresh])
useEffect(() => { if (!servers.some(server => server.status === 'connecting')) return; const timer = setInterval(() => { void refresh().catch(() => undefined) }, 3000); return () => clearInterval(timer) }, [servers, refresh])
const action = async (id: string, operation: () => Promise<unknown>): Promise<void> => {
if (busyRef.current.has(id)) return
busyRef.current.add(id); setBusy(new Set(busyRef.current)); setError('')
try { await operation() } catch (reason) { if (alive.current) setError((reason as Error).message || 'MCP 操作失败') }
finally { try { await refresh() } catch { if (alive.current) setError('刷新 MCP 配置失败') }; busyRef.current.delete(id); if (alive.current) setBusy(new Set(busyRef.current)) }
}
const close = (): void => { if (editor) { if (window.confirm('离开配置编辑器?未保存的修改将丢失。')) setEditor(null); return }; setModal(false); requestAnimationFrame(() => opener.current?.focus()) }
const color = (server: McpServerView): string => !server.enabled ? '#8c8c8c' : server.status === 'connected' ? '#52c41a' : server.status === 'connecting' ? '#faad14' : server.status === 'error' ? '#ff6b6b' : '#8c8c8c'
const status = (server: McpServerView): string => !server.enabled ? '已禁用' : ({ connected: '已连接', connecting: '连接中', disconnected: '未连接', error: '连接失败' })[server.status]
const icon = (server: McpServerView): React.JSX.Element => <span style={{ width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', background: mcpIconColor(server.name) }}>{server.name.charAt(0).toUpperCase()}</span>
const matches = (server: McpServerView, needle: string): boolean => server.name.toLowerCase().includes(needle.trim().toLowerCase())
const visible = servers.filter(server => matches(server, query)), listed = servers.filter(server => matches(server, listQuery)), selected = servers.find(server => server.id === detail)
const enabledTools = servers.filter(server => server.enabled).reduce((sum, server) => sum + server.tools.filter(tool => tool.enabled).length, 0)
const approve = (server: McpServerView): Promise<void> => action(server.id, async () => { await window.ycode.mcp.trust(server.id, server.configHash); await window.ycode.mcp.setEnabled(server.id, true) })
const portalHost = document.querySelector('.yc-capability-center .expert-center-page') ?? document.body
return <div className="ycode-connectors connectors-page"><div className="connector-panel">
<CapabilityToolbar><div className="ycode-connectors connectors-toolbar"><div className="ec-search-wrapper"><Search value={query} onChange={setQuery} placeholder="搜索连接器" /></div><button ref={opener} className="wb-button wb-button--primary wb-button--medium connector-topbar-action-btn" onClick={() => { setModal(true); void refresh().catch(() => undefined) }}><span className="wb-button__icon wb-button__icon--left"><ConnectorEditIcon /></span><span className="wb-button__content">自定义连接器</span></button></div></CapabilityToolbar>
<div className="connector-panel-content">{error && <div className="yc-mcp-error" role="alert">{error}</div>}{loading ? <div className="connector-panel-empty">加载中…</div> : !visible.length ? <div className="connector-panel-empty"><ConnectorEmptyIcon /><p className="connector-panel-empty-title">{query ? '未找到连接器' : '暂无连接器'}</p><p className="connector-panel-empty-desc">{query ? '试试其他关键词' : '连接器目录尚未接入,可添加并管理自定义 MCP 服务。'}</p></div> : <div className="connector-grid">{visible.map(server => <div key={server.id} className="connector-card connector-card--installed" role="button" tabIndex={0} onClick={() => setDetail(server.id)} onKeyDown={event => { if (event.key === 'Enter') setDetail(server.id) }}><div className="connector-card-content"><div className="connector-card-icon">{icon(server)}</div><div className="connector-card-main"><div className="connector-card-name-row"><div className="connector-card-name">{server.name}</div><span className={`connector-card-status-dot connector-card-status-dot--${server.status}`} title={status(server)} /></div><div className="connector-card-desc">{server.tools.length ? `${server.tools.filter(tool => tool.enabled).length}/${server.tools.length} 个工具已启用` : '自定义 MCP 服务'}</div></div><div className="connector-card-action" onClick={event => event.stopPropagation()}><button className="connector-connect-btn" disabled={busy.has(server.id)} aria-label={`管理 ${server.name}`} onClick={() => { setModal(true); setExpanded(new Set([server.id])) }}><ConnectorAddIcon /></button></div></div></div>)}</div>}</div>
</div>
{modal && createPortal(<div className="ycode-connectors"><div className="mcp-modal-mask"><section className="mcp-modal" role="dialog" aria-modal="true" aria-label="MCP 服务管理"><header className="mcp-modal-header"><div className="mcp-modal-header-left"><div className="mcp-modal-header-text"><h2 className="mcp-modal-title">MCP 服务管理</h2><p className="mcp-modal-subtitle">安装 MCP 服务,为 AI 扩展更多工具能力</p></div></div><div className="mcp-modal-header-right"><SourceButton icon={<McpAddIcon />} onClick={() => setEditor({})} disabled={!!editor}>添加 MCP</SourceButton><button className="mcp-modal-close" aria-label="关闭 MCP 服务管理" onClick={close}><McpCloseIcon /></button></div></header><div className="mcp-modal-body">
{/* The panel is portaled above the card list, so a failed approval, toggle,
tool switch or removal has to be reported inside the modal itself. */}
{error && <div className="yc-mcp-error" role="alert">{error}</div>}
{editor ? <ServerEditor key={editor.server?.id || 'new'} server={editor.server} onSaved={refresh} onBack={() => setEditor(null)} /> : <div className="mcp-panel">
<div className="mcp-panel-search" style={{ display: 'flex', alignItems: 'center', gap: 8 }}><div style={{ flex: 1 }}><Search mcp value={listQuery} onChange={setListQuery} placeholder="搜索MCP" /></div><SourceButton icon={<McpHubIcon />} onClick={() => void window.ycode.resources.openExternal('https://cloud.tencent.com/developer/mcp').catch(() => setError('无法打开 MCP Hub'))}>MCP Hub</SourceButton></div>
{servers.length > 0 && <div className="mcp-panel-section-header"><h3 className="mcp-panel-section-title">我的 MCP<span className="mcp-installed-badge">({listed.length})</span></h3><span className="mcp-panel-status-summary">{servers.filter(server => server.enabled).length} 启用{servers.some(server => server.status === 'error') && <span className="status-count-error"> · {servers.filter(server => server.status === 'error').length} 失败</span>}</span></div>}
{enabledTools > 80 && !warningDismissed && <div className="mcp-tools-warning"><div className="mcp-tools-warning-content"><span className="mcp-tools-warning-icon">⚠</span><div className="mcp-tools-warning-text"><strong>启用工具数量过多</strong><p>启用超过 80 个工具可能影响 AI 回复质量,建议禁用不常用的工具。</p></div></div><button className="mcp-tools-warning-dismiss" onClick={() => setWarningDismissed(true)}>知道了</button></div>}
<div className="mcp-panel-content">{!listed.length ? <div className="mcp-panel-empty"><ConnectorEmptyIcon /><p className="mcp-panel-empty-title">{listQuery ? '未找到相关 MCP' : '暂无 MCP 服务'}</p><p className="mcp-panel-empty-desc">{listQuery ? '试试其他关键词' : '添加 MCP 服务,为 AI 扩展更多工具能力'}</p>{!listQuery && <SourceButton onClick={() => setEditor({})}>添加 MCP</SourceButton>}</div> : <div className="mcp-panel-list">{listed.map(server => {
const open = expanded.has(server.id), working = busy.has(server.id)
return <div className={'mcp-item' + (server.status === 'error' ? ' mcp-item--error' : '')} key={server.id}><div className="mcp-item-header" onClick={() => setExpanded(previous => { const next = new Set(previous); if (next.has(server.id)) next.delete(server.id); else next.add(server.id); return next })}><ChevronIcon expanded={open} /><div className="mcp-item-icon" style={{ background: mcpIconColor(server.name) }}>{server.name.charAt(0).toUpperCase()}</div><div className="mcp-item-info"><div className="mcp-item-name">{server.name}<span className={'mcp-item-status' + (server.status === 'connecting' ? ' mcp-item-status--connecting' : '')} style={{ backgroundColor: color(server) }} title={status(server)} /></div><div className="mcp-item-meta">{server.tools.length ? `${server.tools.filter(tool => tool.enabled).length}/${server.tools.length} 个工具已启用` : '工具'}</div></div><div className="mcp-item-actions" onClick={event => event.stopPropagation()}>{open && <><button className="mcp-item-action" title="编辑配置" aria-label={`编辑 ${server.name}`} onClick={() => setEditor({ server })} disabled={working}>编辑</button>{server.enabled && server.trusted && <button className="mcp-item-action" title="重新连接" aria-label={`重新连接 ${server.name}`} disabled={working || server.status === 'connecting'} onClick={() => void action(server.id, async () => { await window.ycode.mcp.disconnect(server.id); await window.ycode.mcp.connect(server.id) })}><RefreshIcon /></button>}<button className="mcp-item-action" title="删除" aria-label={`删除 ${server.name}`} onClick={() => setConfirmRemove(server)} disabled={working}>删除</button></>}<span className="mcp-toggle-wrap"><button className={'mcp-toggle' + (server.enabled && server.trusted ? ' mcp-toggle--checked' : '') + (!server.trusted || working ? ' mcp-toggle--disabled' : '')} type="button" role="switch" aria-label={`${server.name} 启用`} aria-checked={server.enabled && server.trusted} disabled={!server.trusted || working} onClick={() => void action(server.id, () => window.ycode.mcp.setEnabled(server.id, !server.enabled))}><span className="mcp-toggle-thumb" /></button></span></div></div>
{!server.trusted && <div className="mcp-item-approval"><div className="mcp-item-approval-text">此 MCP 配置尚未批准</div><div className="mcp-item-approval-actions"><button className="mcp-item-approval-btn mcp-item-approval-btn--approve" disabled={working} onClick={() => void approve(server)}>批准配置</button></div></div>}
{server.error && <div className="mcp-item-error"><div className="mcp-item-error-content">{server.error}</div><div className="mcp-item-error-actions"><button className="mcp-item-error-link" disabled={working || !server.trusted} onClick={() => void action(server.id, () => window.ycode.mcp.connect(server.id))}><RefreshIcon />重新连接</button><button className="mcp-item-error-link" onClick={() => void navigator.clipboard.writeText(server.error!).catch(() => setError('复制失败'))}><CopyIcon />复制日志</button></div></div>}
{open && server.tools.length > 0 && <div className="mcp-item-tools">{server.tools.map(tool => <span className="mcp-item-tool-wrap" key={tool.name} title={tool.description}><button className={'mcp-item-tool' + (!tool.enabled ? ' mcp-item-tool--disabled' : '')} disabled={working || !server.enabled || !server.trusted} onClick={() => void action(server.id, () => window.ycode.mcp.setToolEnabled(server.id, tool.name, !tool.enabled))}>{tool.name}</button></span>)}</div>}
</div>
})}</div>}</div>
</div>}</div></section></div>{confirmRemove && <div className="yc-mcp-confirm-mask"><section className="yc-mcp-confirm" role="alertdialog" aria-modal="true" aria-label="删除 MCP"><h3>删除 MCP 服务</h3><p>确定删除“{confirmRemove.name}”吗?</p><div><SourceButton onClick={() => setConfirmRemove(null)} disabled={busy.has(confirmRemove.id)}>取消</SourceButton><SourceButton primary disabled={busy.has(confirmRemove.id)} onClick={() => void action(confirmRemove.id, async () => { await window.ycode.mcp.remove(confirmRemove.id); setConfirmRemove(null) })}>删除</SourceButton></div></section></div>}</div>, portalHost)}
{selected && createPortal(<div className="ycode-connectors"><div className="connector-detail-overlay connector-detail-modal-mask" onClick={event => { if (event.target === event.currentTarget) setDetail(null) }}><section className="connector-detail-modal connector-detail-modal--connected" role="dialog" aria-modal="true" aria-label={selected.name}><button className="connector-detail-close" type="button" aria-label="关闭详情" onClick={() => setDetail(null)}><CloseIcon /></button><div className="connector-detail-modal-body"><div className="connector-detail-flow"><div className="connector-detail-flow-avatar connector-detail-flow-avatar--workbuddy"><img src={SOURCE_BUDDY_ICON} alt="" draggable={false} /></div><span className="connector-detail-flow-icon"><span /><span /><span /></span><div className="connector-detail-flow-avatar connector-detail-flow-avatar--connector">{icon(selected)}</div></div><h3 className="connector-detail-name">{selected.name}</h3><p className="connector-detail-desc">{status(selected)} · {selected.tools.length} 个工具</p><div className="connector-detail-actions"><button className="connector-detail-btn connector-detail-btn--try" onClick={() => { setDetail(null); setModal(true); setExpanded(new Set([selected.id])) }}>管理 MCP</button></div>{selected.tools.length > 0 && <div className="connector-detail-expand"><div className="connector-detail-tools">{selected.tools.map(tool => <div className="connector-detail-tool-item" key={tool.name}><span className="connector-detail-tool-name">{tool.name}</span><span className="connector-detail-tool-desc">{tool.description}</span></div>)}</div></div>}</div></section></div></div>, portalHost)}
</div>
}
@@ -0,0 +1,292 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType } from 'react'
import type { RecommendedSkill, SkillDocument, SkillInstallProgress, SkillRecord } from '@shared/capabilities'
import { CardFrame, PlusIcon, UseSkillIcon } from './skills-source.mjs'
import { SkillDetailShell } from './SkillDetailShell'
import { SkillAvatar, skillAvatarUrl } from './SkillAvatar'
import MarkdownContent from '../components/MarkdownContent'
import './recommended-flow.css'
export interface RecommendedSkillsProps {
cwd?: string
installed: SkillRecord[]
onUseSkill?(skill: SkillRecord): void
onInstalledChange(): void
onViewInstalled(): void
}
type InstallPhase = SkillInstallProgress['phase'] | 'idle'
const isRunning = (phase: string): boolean => ['downloading', 'validating', 'installing'].includes(phase)
interface InstallView {
recommendation: RecommendedSkill
operationId: string
phase: InstallPhase
receivedBytes: number
totalBytes?: number
error?: string
overwrite: boolean
}
/** The recovered card supports a source label, but its published type does not declare it. */
const Card = CardFrame as ComponentType<{
title: string
description: string
sourceLabel?: string
iconUrl?: string
disabled?: boolean
onOpen?(): void
actions?: React.ReactNode
className?: string
}>
const PHASE_LABEL: Record<InstallPhase, string> = {
idle: '准备安装',
downloading: '下载中',
validating: '校验中',
installing: '安装中',
installed: '已安装',
cancelled: '已取消',
failed: '安装失败'
}
function operationId(): string {
const globalCrypto = window.crypto
if (globalCrypto?.randomUUID) return globalCrypto.randomUUID()
// The host rejects a non-UUID operation id, so derive a v4 UUID from the CSPRNG.
const bytes = new Uint8Array(16)
globalCrypto.getRandomValues(bytes)
bytes[6] = (bytes[6] & 0x0f) | 0x40
bytes[8] = (bytes[8] & 0x3f) | 0x80
const hex = Array.from(bytes, value => value.toString(16).padStart(2, '0')).join('')
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
}
/** Compare the recommendation against the installed list, honouring local edits. */
export function matchInstalled(
recommendation: RecommendedSkill,
installed: SkillRecord[]
): { skill?: SkillRecord; state: 'none' | 'current' | 'outdated' | 'modified' | 'conflict' } {
const normalize = (value: string): string => value.normalize('NFKC').toLowerCase()
// Only a managed user skill can be replaced; a workspace skill is never overwritten.
const byName = installed.filter(skill => skill.scope === 'user' && normalize(skill.name) === normalize(recommendation.name))
if (!byName.length) return { state: 'none' }
const origin = byName.find(skill => skill.origin?.recommendationId === recommendation.id)
if (!origin) return { skill: byName[0], state: 'conflict' }
const installedOrigin = origin.origin!
if (origin.userModified) return { skill: origin, state: 'modified' }
const same = installedOrigin.version === recommendation.version
&& installedOrigin.archiveSha256 === recommendation.sha256
&& installedOrigin.commit === recommendation.commit
&& installedOrigin.repository === recommendation.repository
&& installedOrigin.packagePath === recommendation.packagePath
return same && !origin.error ? { skill: origin, state: 'current' } : { skill: origin, state: 'outdated' }
}
function InstallDisclosure({ recommendation, installed }: { recommendation: RecommendedSkill; installed?: SkillRecord }): React.JSX.Element {
return <div className="yc-skill-install-disclosure">
<strong>{installed ? '确认安装推荐版本并替换' : '确认安装此技能'}</strong>
<p>将从 GitHub 下载固定版本 <b>{recommendation.repository}</b> 的 {recommendation.packagePath}(版本 {recommendation.version},提交 {recommendation.commit.slice(0, 12)})。</p>
<p>{installed ? '替换后保留当前启停状态。' : '安装后默认启用,模型可以在需要时加载它。'}{recommendation.containsScripts ? '该技能包含脚本,运行仍受当前工具权限与审批规则约束。' : ''}安装过程只下载并保存文件,不会执行仓库中的任何安装命令。</p>
{installed && <p>本机已存在「{installed.name}」{installed.userModified ? ',且本地内容已被修改' : ''}。当前来源:{installed.origin ? `${installed.origin.repository}@${installed.origin.commit.slice(0, 12)} · v${installed.origin.version}` : '本地导入'};替换为 v{recommendation.version},保留{installed.enabled ? '启用' : '禁用'}状态。</p>}
</div>
}
function InstalledOverview({ skill, cwd }: { skill?: SkillRecord; cwd?: string }): React.JSX.Element | null {
const [document, setDocument] = useState<SkillDocument | null>(null)
const [error, setError] = useState('')
useEffect(() => {
let alive = true
setDocument(null); setError('')
if (skill && !skill.error) void window.ycode.skills.read(skill.id, cwd).then(value => {
if (alive) setDocument(value)
}).catch(reason => { if (alive) setError(String(reason.message ?? reason)) })
return () => { alive = false }
}, [skill?.id, skill?.origin?.installedAt, skill?.userModified, skill?.error, cwd])
if (!skill || skill.error) return null
return <details className="yc-skill-source-details yc-skill-installed-overview">
<summary>技能说明</summary>
{error ? <p className="yc-skill-error" role="alert">{error}</p> : document
? <MarkdownContent content={document.content} imageUrlResolver={async () => undefined} /> : <p role="status">正在读取技能说明…</p>}
</details>
}
export function RecommendedSkills({ cwd, installed, onUseSkill, onInstalledChange, onViewInstalled }: RecommendedSkillsProps): React.JSX.Element {
const [recommendations, setRecommendations] = useState<RecommendedSkill[]>([])
const [loading, setLoading] = useState(true)
const [unavailable, setUnavailable] = useState('')
const [view, setView] = useState<InstallView | null>(null)
const [detail, setDetail] = useState<RecommendedSkill | null>(null)
const [error, setError] = useState('')
const [notice, setNotice] = useState<RecommendedSkill | null>(null)
const [foreign, setForeign] = useState<SkillInstallProgress | null>(null)
const [cancelPending, setCancelPending] = useState(false)
const [enabling, setEnabling] = useState(false)
const latestProgress = useRef<SkillInstallProgress | null>(null)
const submitting = useRef(false)
const active = useRef<InstallView | null>(null)
active.current = view
const changedRef = useRef(onInstalledChange)
changedRef.current = onInstalledChange
useEffect(() => {
let alive = true
if (typeof window.ycode.skills.recommendations !== 'function') { setUnavailable('当前版本未提供推荐技能目录'); setLoading(false); return }
void window.ycode.skills.recommendations().then(values => {
if (!alive) return
setRecommendations(values)
setUnavailable(values.length ? '' : '当前没有可用的内置推荐技能')
}).catch(reason => { if (alive) setUnavailable(String(reason.message ?? reason)) })
.finally(() => { if (alive) setLoading(false) })
return () => { alive = false }
}, [])
useEffect(() => {
if (!notice) return
const timer = window.setTimeout(() => setNotice(null), 6000)
return () => window.clearTimeout(timer)
}, [notice])
const applyProgress = useCallback((progress: SkillInstallProgress): void => {
const previous = latestProgress.current
if (previous && (progress.updatedAt < previous.updatedAt || progress.updatedAt === previous.updatedAt &&
(progress.operationId !== previous.operationId || !isRunning(previous.phase)))) return
latestProgress.current = progress
const current = active.current
if (current?.operationId === progress.operationId) {
const next = { ...current, phase: progress.phase, receivedBytes: progress.receivedBytes, totalBytes: progress.totalBytes, error: progress.error }
active.current = next
setView(next)
setForeign(null)
if (!isRunning(progress.phase)) {
submitting.current = false
setCancelPending(false)
if (progress.phase === 'installed') setNotice(current.recommendation)
}
} else setForeign(isRunning(progress.phase) ? progress : null)
if (progress.phase === 'installed') changedRef.current()
}, [])
useEffect(() => {
let alive = true
const api = window.ycode.skills
if (typeof api.onInstallProgress !== 'function') return
const unsubscribe = api.onInstallProgress(progress => { if (alive) applyProgress(progress) })
void api.installState().then(state => { if (alive && state) applyProgress(state) }).catch(() => undefined)
return () => { alive = false; unsubscribe() }
}, [applyProgress])
const matches = useMemo(() => recommendations.map(recommendation => ({ recommendation, match: matchInstalled(recommendation, installed) })), [recommendations, installed])
const operationFor = (recommendation: RecommendedSkill): SkillInstallProgress | InstallView | null =>
foreign?.recommendationId === recommendation.id ? foreign : view?.recommendation.id === recommendation.id ? view : null
const running = !!(view && isRunning(view.phase) || foreign && isRunning(foreign.phase))
const begin = (recommendation: RecommendedSkill): void => {
setDetail(recommendation)
if (running || submitting.current) return
setError('')
const next: InstallView = { recommendation, operationId: operationId(), phase: 'idle', receivedBytes: 0, totalBytes: recommendation.expectedBytes, overwrite: false }
active.current = next; setView(next)
}
const run = async (overwrite: boolean): Promise<void> => {
const current = active.current
if (!current || current.phase !== 'idle' || submitting.current || foreign && isRunning(foreign.phase)) return
const operation = current.operationId
submitting.current = true
setError('')
const next: InstallView = { ...current, phase: 'downloading', overwrite }
active.current = next; setView(next)
try {
await window.ycode.skills.installRecommended(current.recommendation.id, { operationId: operation, overwrite })
if (active.current?.operationId !== operation) return
submitting.current = false
const completed: InstallView = { ...active.current, phase: 'installed', error: undefined }
active.current = completed; setView(completed)
setNotice(current.recommendation)
changedRef.current()
} catch (reason) {
if (active.current?.operationId !== operation) return
submitting.current = false
const state = await window.ycode.skills.installState().catch(() => null)
if (active.current?.operationId !== operation) return
if (state?.operationId === operation && !isRunning(state.phase)) { applyProgress(state); return }
const failed: InstallView = { ...active.current, phase: 'failed', error: String((reason as Error).message ?? reason) }
active.current = failed; setView(failed)
}
}
const cancel = async (): Promise<void> => {
const current = view && isRunning(view.phase) ? view : foreign
if (!current || !isRunning(current.phase) || current.phase === 'installing' || cancelPending) return
setCancelPending(true)
try {
await window.ycode.skills.cancelInstall(current.operationId)
const state = await window.ycode.skills.installState()
if (state) applyProgress(state)
} catch (reason) { setError(String((reason as Error).message ?? reason)) }
finally { setCancelPending(false) }
}
const useSkill = async (skill: SkillRecord): Promise<void> => {
if (!onUseSkill || skill.error || !skill.userInvocable || enabling || running) return
if (!skill.enabled) {
setEnabling(true); setError('')
try {
await window.ycode.skills.setEnabled(skill.id, true, cwd)
const refreshed = (await window.ycode.skills.list(cwd)).find(item => item.id === skill.id)
changedRef.current()
if (!refreshed?.enabled || refreshed.error || !refreshed.userInvocable) throw Error('技能当前不可使用,请刷新后重试')
onUseSkill(refreshed); setDetail(null); setNotice(null)
} catch (reason) { setError(String((reason as Error).message ?? reason)) }
finally { setEnabling(false) }
} else { onUseSkill(skill); setDetail(null); setNotice(null) }
}
const useButton = (recommendation: RecommendedSkill, skill: SkillRecord | undefined, primary = false): React.ReactNode =>
skill && !skill.error && skill.userInvocable && onUseSkill && <button type="button"
className={primary ? 'sm-skill-detail-btn sm-skill-detail-btn--primary' : 'yc-skill-recommendation-link'}
aria-label={`使用推荐技能 ${recommendation.name}`} disabled={enabling || running}
onClick={event => { event.stopPropagation(); void useSkill(skill) }}><UseSkillIcon />{enabling ? '正在启用…' : skill.enabled ? '去使用' : '启用后使用'}</button>
if (loading) return <div className="sm-empty" role="status">加载中…</div>
if (unavailable) return <div className="sm-empty yc-skill-recommendation-unavailable"><p>推荐技能目录暂不可用</p><p>{unavailable}</p><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div>
return <>
{error && !detail && <p className="yc-skill-error" role="alert">{error}</p>}
{notice && <div className="yc-skill-notice yc-skill-install-success" role="status"><span>已安装 {notice.name}</span>{useButton(notice, matchInstalled(notice, installed).skill)}<button type="button" className="yc-skill-recommendation-link" aria-label="关闭安装成功提示" onClick={() => setNotice(null)}>关闭</button></div>}
{foreign && <div className="yc-skill-recommendation-progress" role="status" data-foreign-install="true" data-install-phase={foreign.phase}><span>其他窗口正在安装推荐技能({PHASE_LABEL[foreign.phase]})</span><button type="button" disabled={foreign.phase === 'installing' || cancelPending} onClick={() => void cancel()}>取消</button></div>}
<div className="sm-grid yc-skill-recommendation-grid">{matches.map(({ recommendation, match }) => {
const operation = operationFor(recommendation)
const pending = !!operation && isRunning(operation.phase)
const status = pending ? PHASE_LABEL[operation.phase] : match.state === 'current' ? '已安装' : match.state === 'modified' ? '已安装(本地已修改)' : match.state === 'outdated' ? '已安装(版本不同)' : match.state === 'conflict' ? '已存在同名本地技能' : '未安装'
return <div key={recommendation.id} className="yc-skill-card-keyboard yc-skill-recommendation-card" data-recommendation-id={recommendation.id} data-recommendation-state={match.state} data-install-phase={operation?.phase} role="button" tabIndex={0} aria-label={`查看 ${recommendation.name}`}
onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); setDetail(recommendation) } }}>
<Card title={recommendation.name} description={recommendation.description} iconUrl={skillAvatarUrl(recommendation.id)} sourceLabel={status} onOpen={() => setDetail(recommendation)} actions={<div className="sm-status" onClick={event => event.stopPropagation()}><button type="button" className={'sm-add-btn' + (pending ? ' sm-add-btn--loading' : '')} aria-label={`安装 ${recommendation.name}`} disabled={running || match.state === 'current'} title={running ? '当前有推荐技能正在安装' : undefined} onClick={() => begin(recommendation)}>{pending ? <span className="yc-skill-recommendation-spinner" aria-hidden /> : <PlusIcon />}</button></div>} />
<p className="yc-skill-recommendation-meta"><span>{recommendation.author}</span><span>v{recommendation.version}</span><span>{recommendation.license}</span>{foreign && <span className="yc-skill-recommendation-busy">其他窗口正在安装</span>}{useButton(recommendation, match.skill)}</p>
</div>
})}</div>
<div className="yc-skill-recommendation-footer"><button type="button" className="wb-button wb-button--secondary wb-button--medium" onClick={onViewInstalled}>查看已安装技能</button></div>
{detail && (() => {
const match = matchInstalled(detail, installed)
const operation = operationFor(detail)
const pending = !!operation && isRunning(operation.phase)
const confirming = operation?.phase === 'idle'
const canInstall = match.state !== 'current'
return <SkillDetailShell title={detail.name} description={detail.description} ariaLabel={`推荐技能详情 ${detail.name}`} recommendationId={detail.id}
avatar={<SkillAvatar name={detail.name} recommendationId={detail.id} size={68} />}
metadata={<><span>v{detail.version}</span><span>{detail.license}</span><span>{detail.author}</span>{detail.containsScripts && <span>包含脚本</span>}</>}
onClose={() => { setDetail(null); if (active.current?.phase === 'idle') { active.current = null; setView(null) } }}
actions={<>
{pending ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" disabled><span className="yc-skill-recommendation-spinner" />{PHASE_LABEL[operation.phase]}</button><button type="button" className="sm-skill-detail-btn" disabled={operation.phase === 'installing' || cancelPending} onClick={() => void cancel()}>{cancelPending ? '正在请求取消…' : '取消安装'}</button></>
: confirming ? <><button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label="确认安装推荐技能" onClick={() => void run(!!match.skill)}>{match.skill ? '替换并安装' : '确认安装'}</button><button type="button" className="sm-skill-detail-btn" onClick={() => { active.current = null; setView(null) }}>返回详情</button></>
: <>{useButton(detail, match.skill, true)}{canInstall && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label={`安装推荐版本 ${detail.name}`} disabled={running} onClick={() => begin(detail)}>{operation?.phase === 'failed' || operation?.phase === 'cancelled' ? '重试安装' : match.skill ? '安装推荐版本' : '安装'}</button>}</>}
</>}>
{confirming && <InstallDisclosure recommendation={detail} installed={match.skill} />}
{operation && operation.phase !== 'idle' && <div className="yc-skill-inline-install" role="status" aria-live="polite" data-install-phase={operation.phase} data-operation-id={operation.operationId}>
<span>{PHASE_LABEL[operation.phase]}{operation.phase === 'downloading' && operation.totalBytes ? ` · ${Math.round(operation.receivedBytes / operation.totalBytes * 100)}%` : ''}</span>
{pending && <progress max={operation.totalBytes || 1} value={operation.phase === 'downloading' ? operation.receivedBytes : operation.totalBytes || 1} />}
{operation.phase === 'failed' && operation.error && <p className="yc-skill-error" role="alert">{operation.error}</p>}
</div>}
{error && <p className="yc-skill-error" role="alert">{error}</p>}
<details className="yc-skill-source-details"><summary>来源与校验信息</summary><dl><dt>仓库</dt><dd><button type="button" className="yc-skill-recommendation-link" onClick={() => { void window.ycode.resources.openExternal(`https://github.com/${detail.repository}`).catch(() => undefined) }}>{detail.repository}</button></dd><dt>提交</dt><dd>{detail.commit}</dd><dt>安装包</dt><dd>{detail.packagePath}</dd><dt>SHA-256</dt><dd>{detail.sha256}</dd></dl></details>
<InstalledOverview skill={match.skill} cwd={cwd} />
</SkillDetailShell>
})()}
</>
}
@@ -0,0 +1,17 @@
import { useState } from 'react'
import archifyAvatar from './assets/archify-owner-avatar.jpg'
export function skillAvatarUrl(recommendationId?: string): string | undefined {
return recommendationId === 'archify' ? archifyAvatar : undefined
}
export function SkillAvatar({ name, recommendationId, size = 48, borderRadius = 16 }: {
name: string; recommendationId?: string; size?: number; borderRadius?: number
}): React.JSX.Element {
const source = skillAvatarUrl(recommendationId)
const [failed, setFailed] = useState<string>()
const style = { width: size, height: size, borderRadius }
return source && failed !== source
? <img className="yc-skill-avatar" src={source} alt="" style={{ ...style, objectFit: 'cover' }} onError={() => setFailed(source)} />
: <span className="yc-skill-avatar" aria-hidden style={style}>{Array.from(name.trim())[0]?.toUpperCase() ?? 'S'}</span>
}
@@ -0,0 +1,34 @@
import { useRef, useState, type ReactNode } from 'react'
import { CloseIcon } from './skills-source.mjs'
import { SkillModalPortal } from './SkillModalPortal'
import { useModalFocus } from './skill-modal-focus'
import './skill-detail-shell.css'
interface Props {
title: string; description: string; avatar: ReactNode; metadata?: ReactNode; actions?: ReactNode
secondary?: ReactNode; children?: ReactNode; ariaLabel?: string; recommendationId?: string
onClose(): void
}
export function SkillDetailShell(props: Props): React.JSX.Element {
return <SkillModalPortal><DetailPanel {...props} /></SkillModalPortal>
}
function DetailPanel({ title, description, avatar, metadata, actions, secondary, children, ariaLabel, recommendationId, onClose }: Props): React.JSX.Element {
const dialog = useRef<HTMLDivElement>(null)
const [expanded, setExpanded] = useState(false)
useModalFocus(dialog, onClose)
return <div className="sm-skill-detail-overlay" onClick={event => { if (event.target === event.currentTarget) onClose() }}>
<div className="sm-skill-detail-card is-skill-view yc-skill-detail-shell" ref={dialog} tabIndex={-1} role="dialog" aria-modal="true" aria-label={ariaLabel ?? title} data-recommendation-id={recommendationId}>
<div className="sm-skill-detail-header"><div className="sm-skill-detail-icon-wrap">{avatar}</div><div className="sm-skill-detail-header-body">
<div className="sm-skill-detail-title-row"><h2>{title}</h2><div className="sm-skill-detail-secondary">{secondary}<button type="button" className="sm-skill-detail-close" aria-label="关闭技能详情" onClick={onClose}><CloseIcon /></button></div></div>
<div className="sm-skill-detail-action-row">{actions}</div>
</div></div>
<div className="yc-skill-detail-scroll">
<div className="yc-skill-detail-description"><p className={expanded ? '' : 'is-collapsed'}>{description}</p>{description.length > 110 && <button type="button" className="yc-skill-recommendation-link" aria-expanded={expanded} onClick={() => setExpanded(value => !value)}>{expanded ? '收起' : '展开'}</button>}</div>
{metadata && <div className="yc-skill-detail-metadata">{metadata}</div>}
{children}
</div>
</div>
</div>
}
@@ -0,0 +1,49 @@
import { useEffect, useLayoutEffect, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
const hosts: HTMLElement[] = []
const saved = new Map<HTMLElement, { inert: boolean; hidden: string | null }>()
function isolateBackground(): void {
const top = hosts.at(-1)
for (const child of document.body.children) {
if (!(child instanceof HTMLElement) || child.tagName === 'SCRIPT') continue
if (!saved.has(child)) saved.set(child, { inert: child.inert, hidden: child.getAttribute('aria-hidden') })
child.inert = child !== top
if (child !== top) child.setAttribute('aria-hidden', 'true')
else child.removeAttribute('aria-hidden')
}
}
/** A body-level themed host escapes the capability center's isolated stacking context. */
export function SkillModalPortal({ children }: { children: ReactNode }): React.JSX.Element {
const [host] = useState(() => {
const element = document.createElement('div')
element.className = 'yc-capability-center yc-skill-modal-host'
return element
})
const [theme, setTheme] = useState(() => document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light')
useLayoutEffect(() => {
document.body.append(host); hosts.push(host); isolateBackground()
const observer = new MutationObserver(isolateBackground)
observer.observe(document.body, { childList: true })
return () => {
observer.disconnect()
hosts.splice(hosts.indexOf(host), 1); host.remove()
if (hosts.length) isolateBackground()
else {
for (const [element, previous] of saved) {
element.inert = previous.inert
if (previous.hidden === null) element.removeAttribute('aria-hidden'); else element.setAttribute('aria-hidden', previous.hidden)
}
saved.clear()
}
}
}, [host])
useEffect(() => {
const observer = new MutationObserver(() => setTheme(document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light'))
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
return () => observer.disconnect()
}, [])
return createPortal(<div className={`expert-center-${theme} yc-skill-modal-theme`}>{children}</div>, host)
}
+121 -3
View File
@@ -1,5 +1,123 @@
import CapabilityMaintenance from './CapabilityMaintenance'
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState, type ReactElement, type ReactNode } from 'react'
import type { SkillDocument, SkillRecord } from '@shared/capabilities'
import { CapabilitySubpageHeader, CapabilityToolbar } from './CapabilityCenter'
import { SkillsIcon, PinIcon, UnpinIcon, SearchIcon, ClearFilledIcon } from './SkillSourceIcons'
import { CardFrame, CardMoreIcon, PlusIcon, TrashIcon, FolderIcon, CloseIcon, UseSkillIcon, DocIcon, setSkillPrimitives } from './skills-source.mjs'
import { RecommendedSkills } from './RecommendedSkills'
import { useModalFocus } from './skill-modal-focus'
import { SkillDetailShell } from './SkillDetailShell'
import { SkillModalPortal } from './SkillModalPortal'
import { SkillAvatar, skillAvatarUrl } from './SkillAvatar'
import MarkdownContent from '../components/MarkdownContent'
import './skills-source.css'
import './skills-host.css'
export default function SkillsPage(): React.JSX.Element {
return <CapabilityMaintenance title="技能" />
export interface SkillsPageProps { cwd?: string; onUseSkill?(skill: SkillRecord): void; onCreateSkill?(): void; onFindSkill?(): void }
function Avatar({ name, iconUrl, size = 48, borderRadius = 16 }: { name: string; iconUrl?: string; size?: number; borderRadius?: number }): React.JSX.Element {
return <SkillAvatar name={name} recommendationId={iconUrl && iconUrl === skillAvatarUrl('archify') ? 'archify' : undefined} size={size} borderRadius={borderRadius} />
}
function Tooltip({ children, content }: { children: ReactElement<{ title?: string }>; content: ReactNode }): React.JSX.Element { return cloneElement(children, { title: typeof content === 'string' ? content : undefined }) }
function BatchCheckbox({ selected }: { selected: boolean }): React.JSX.Element { return <input className="sm-card-batch-checkbox" type="checkbox" checked={selected} readOnly tabIndex={-1} aria-hidden /> }
setSkillPrimitives({ Avatar, Tooltip, Checkbox: BatchCheckbox })
function Button({ children, primary = false, className = '', ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { primary?: boolean }): React.JSX.Element {
return <button type="button" {...props} className={`wb-button wb-button--${primary ? 'primary' : 'secondary'} wb-button--medium ${className}`}><span className="wb-button__content">{children}</span></button>
}
function Switch({ skill, disabled, large, onToggle }: { skill: SkillRecord; disabled?: boolean; large?: boolean; onToggle(): void }): React.JSX.Element {
return <label className={`wb-switch${large ? ' wb-switch--large sm-skill-detail-toggle' : ''}`} title={skill.error ?? (skill.enabled ? '禁用技能' : '启用技能')}><span className="wb-switch__control"><input className="wb-switch__input" type="checkbox" role="switch" aria-label={`${skill.enabled ? '禁用' : '启用'} ${skill.name}`} checked={skill.enabled} disabled={disabled || !!skill.error} onChange={onToggle} /><span className="wb-switch__track"><span className="wb-switch__thumb" /></span></span></label>
}
function Search({ value, onChange }: { value: string; onChange(value: string): void }): React.JSX.Element {
return <div className="ec-search-wrapper"><span className="wb-input-wrapper wb-input-wrapper--medium"><span className="wb-input__prefix"><SearchIcon /></span><input className="wb-input__inner" aria-label="搜索技能" placeholder="搜索技能" value={value} onChange={event => onChange(event.target.value)} /><button type="button" className={`wb-input__icon-button wb-input__clear${value ? '' : ' wb-input__clear--hidden'}`} aria-label="清空技能搜索" tabIndex={value ? 0 : -1} onClick={() => onChange('')}><ClearFilledIcon /></button></span></div>
}
/** Source layout and SVGs are recovered; native dialogs, storage, and accessibility are local host adapters. */
function ImportModal({ busy, error, onClose, onImport }: { busy: boolean; error: string; onClose(): void; onImport(overwrite: boolean, kind: 'directory' | 'zip'): void }): React.JSX.Element {
const dialog = useRef<HTMLDivElement>(null), [overwrite, setOverwrite] = useState(false)
useModalFocus(dialog, () => { if (!busy) onClose() })
return <div className="skill-upload-modal-mask" onClick={event => { if (event.target === event.currentTarget && !busy) onClose() }}><div ref={dialog} className="skill-upload-modal" role="dialog" aria-modal="true" aria-label="导入技能">
<div className="skill-upload-modal-header"><span>导入技能</span><button type="button" className="skill-upload-modal-close" aria-label="关闭导入技能" disabled={busy} onClick={onClose}><CloseIcon /></button></div>
<button type="button" className={'skill-upload-drop-zone' + (busy ? ' skill-upload-drop-zone--disabled' : '')} aria-label="选择文件夹" disabled={busy} onClick={() => onImport(overwrite, 'directory')}>
{busy && <span className="skill-upload-overlay"><span className="skill-upload-overlay-spinner" /><span className="skill-upload-overlay-text">正在导入技能…</span></span>}
<span className="skill-upload-drop-icon"><svg width="32" height="32" viewBox="0 0 32 32" fill="none"><rect x="3" y="6" width="26" height="20" rx="2" stroke="currentColor" strokeWidth="1.6" /><path d="M3 11h26" stroke="currentColor" strokeWidth="1.6" /><path d="M10 6V3M22 6V3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /><path d="M16 17v5M13 20l3-3 3 3" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" /></svg></span><span className="skill-upload-drop-title">选择技能文件夹</span>
</button>
<div className="skill-upload-requirements"><div className="skill-upload-requirements-title">技能文件要求</div><ul className="skill-upload-requirements-list"><li>文件夹根目录包含 SKILL.md</li><li>SKILL.md 包含 name 和 description 元数据</li></ul><button type="button" className="skill-upload-pick-zip-link" disabled={busy} onClick={() => onImport(overwrite, 'zip')}>选择 ZIP 文件</button></div>
<label className="yc-skill-overwrite"><input type="checkbox" checked={overwrite} disabled={busy} onChange={event => setOverwrite(event.target.checked)} />替换已导入的同名技能</label>
<p className="yc-skill-local-note">导入到本机技能库;当前未连接远程安全扫描服务。</p>{error && <p className="yc-skill-error" role="alert">{error}</p>}
</div></div>
}
function Detail({ skill, document, busy, error, onClose, onToggle, onUse, onExport, onRemove }: { skill: SkillRecord; document: SkillDocument | null; busy: boolean; error: string; onClose(): void; onToggle(): void; onUse?(): void; onExport(): void; onRemove(): void }): React.JSX.Element {
return <SkillDetailShell title={skill.name} description={skill.description || skill.error || ''} onClose={onClose}
avatar={<SkillAvatar name={skill.name} recommendationId={skill.origin?.recommendationId} size={68} />}
secondary={<Switch skill={skill} large disabled={busy} onToggle={onToggle} />}
metadata={<><span>{skill.origin ? '推荐安装' : skill.scope === 'workspace' ? '空间技能' : '本地导入'}</span>{skill.version && <span>v{skill.version}</span>}{skill.origin && <span>{skill.origin.repository}</span>}{skill.disableModelInvocation && <span>仅手动调用</span>}{!skill.userInvocable && <span>仅模型调用</span>}</>}
actions={<>{!skill.error && skill.userInvocable && onUse && <button type="button" className="sm-skill-detail-btn sm-skill-detail-btn--primary" aria-label="使用技能" disabled={busy} onClick={onUse}><UseSkillIcon />{skill.enabled ? '去使用' : '启用后使用'}</button>}{skill.scope === 'user' && <><Button disabled={busy || !!skill.error} onClick={onExport}><FolderIcon />导出</Button><button type="button" className="sm-skill-detail-more-btn" aria-label="卸载技能" disabled={busy} onClick={onRemove}><TrashIcon /></button></>}</>}>
{error && <p className="yc-skill-error" role="alert">{error}</p>}
<section className="yc-skill-description-body"><h3 className="sm-skill-detail-section-title"><DocIcon />技能说明</h3>{document ? <MarkdownContent content={document.content} imageUrlResolver={async () => undefined} /> : <p>{skill.error || '正在读取技能说明…'}</p>}</section>
{document && <details className="yc-skill-source-details"><summary>技能文件与来源</summary><div className="yc-skill-files">{document.files.map(file => <code key={file}>{file}</code>)}</div><p className="yc-skill-local-note">{skill.path}</p>{skill.origin && <p className="yc-skill-local-note">{skill.origin.repository}@{skill.origin.commit}</p>}</details>}
</SkillDetailShell>
}
export default function SkillsPage({ cwd, onUseSkill, onCreateSkill, onFindSkill }: SkillsPageProps): React.JSX.Element {
const [view, setView] = useState<'market' | 'installed'>('market'), [items, setItems] = useState<SkillRecord[]>([]), [query, setQuery] = useState(''), [category, setCategory] = useState('推荐'), [loading, setLoading] = useState(true), [busy, setBusy] = useState(false), [error, setError] = useState(''), [notice, setNotice] = useState(''), [addOpen, setAddOpen] = useState(false), [importOpen, setImportOpen] = useState(false), [importError, setImportError] = useState(''), [selectedId, setSelectedId] = useState<string | null>(null), [document, setDocument] = useState<SkillDocument | null>(null), [detailError, setDetailError] = useState(''), [menuId, setMenuId] = useState<string | null>(null), [batch, setBatch] = useState(false), [selected, setSelected] = useState<string[]>([]), [pendingRemove, setPendingRemove] = useState<SkillRecord[]>([])
const currentCwd = useRef(cwd); currentCwd.current = cwd
const generation = useRef(0), mounted = useRef(true), add = useRef<HTMLDivElement>(null), removeDialog = useRef<HTMLDivElement>(null)
const key = 'ycode.skills.menu.v1:' + (cwd ?? '').replaceAll('\\', '/').toLowerCase()
const [pinned, setPinned] = useState<string[]>([])
useEffect(() => { if (!notice) return; const timer = window.setTimeout(() => setNotice(''), 3000); return () => window.clearTimeout(timer) }, [notice])
useEffect(() => { try { setPinned(readPinned(key)) } catch { setPinned([]) } }, [key])
useEffect(() => { mounted.current = true; return () => { mounted.current = false; generation.current++ } }, [])
const reload = useCallback(async () => { if (currentCwd.current !== cwd) return; const current = ++generation.current; const values = await window.ycode.skills.list(cwd); if (mounted.current && currentCwd.current === cwd && current === generation.current) setItems(values) }, [cwd])
useEffect(() => { setLoading(true); setQuery(''); setSelectedId(null); setSelected([]); setMenuId(null); setError(''); setNotice(''); void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }).finally(() => { if (mounted.current) setLoading(false) }) }, [reload])
// The host pushes a change after every install, import, removal or enable switch.
useEffect(() => {
const api = window.ycode.skills
if (typeof api.onChanged !== 'function') return
return api.onChanged(() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) })
}, [reload])
useEffect(() => { if (!addOpen && !menuId) return; const close = (event: PointerEvent): void => { if (add.current?.contains(event.target as Node) || (event.target as Element)?.closest?.('.sm-card-menu-wrap')) return; setAddOpen(false); setMenuId(null) }; const keydown = (event: KeyboardEvent): void => { if (event.key === 'Escape') { setAddOpen(false); setMenuId(null) } }; documentGlobal().addEventListener('pointerdown', close); window.addEventListener('keydown', keydown); return () => { documentGlobal().removeEventListener('pointerdown', close); window.removeEventListener('keydown', keydown) } }, [addOpen, menuId])
const current = items.find(item => item.id === selectedId) ?? null
useEffect(() => { let active = true; setDocument(null); setDetailError(''); if (current && !current.error) void window.ycode.skills.read(current.id, cwd).then(value => { if (active) setDocument(value) }).catch(reason => { if (active) setDetailError(String(reason.message ?? reason)) }); return () => { active = false } }, [current?.id, current?.error, current?.enabled, cwd])
const run = async (operation: () => Promise<unknown>): Promise<void> => { if (busy) return; setBusy(true); setError(''); setNotice(''); try { await operation() } catch (reason) { if (mounted.current) setError(reason instanceof Error ? reason.message : String(reason)) } finally { try { await reload() } catch (reason) { if (mounted.current) setError(reason instanceof Error ? reason.message : String(reason)) }; if (mounted.current) setBusy(false) } }
const matches = useMemo(() => items.filter(item => `${item.name}\n${item.description}`.toLowerCase().includes(query.trim().toLowerCase())).sort((a, b) => Number(pinned.includes(b.id)) - Number(pinned.includes(a.id))), [items, query, pinned])
const pin = (id: string): void => { const next = pinned.includes(id) ? pinned.filter(value => value !== id) : [...pinned, id]; setPinned(next); let memory = {}; try { memory = JSON.parse(localStorage.getItem(key) ?? '{}') } catch { /* Invalid optional local memory is discarded. */ } localStorage.setItem(key, JSON.stringify({ ...memory, pinned: next })) }
const useSkill = (skill: SkillRecord): void => {
if (busy || !skill.userInvocable || skill.error || !onUseSkill) return
if (skill.enabled) { setSelectedId(null); onUseSkill(skill); return }
void run(async () => {
await window.ycode.skills.setEnabled(skill.id, true, cwd)
const refreshed = (await window.ycode.skills.list(cwd)).find(item => item.id === skill.id)
if (!refreshed?.enabled || refreshed.error || !refreshed.userInvocable) throw Error('技能当前不可使用,请刷新后重试')
setSelectedId(null); onUseSkill(refreshed)
})
}
const importPackage = async (overwrite: boolean, kind: 'directory' | 'zip'): Promise<void> => { if (busy) return; setBusy(true); setImportError(''); try { const value = await window.ycode.skills.importPath(undefined, overwrite, kind); if (value) { await reload(); setImportOpen(false); setView('installed'); setNotice(`已导入 ${value.name}`) } } catch (reason) { setImportError(reason instanceof Error ? reason.message : String(reason)) } finally { setBusy(false) } }
const toggle = (skill: SkillRecord): void => { void run(() => window.ycode.skills.setEnabled(skill.id, !skill.enabled, cwd)) }
const exportPackage = (skill: SkillRecord): void => { void run(async () => { const target = await window.ycode.skills.export(skill.id); if (target) setNotice('已导出技能 ZIP') }) }
const toggleSelected = (id: string): void => setSelected(values => values.includes(id) ? values.filter(value => value !== id) : [...values, id])
return <div className="skill-market yc-skills-page">
{view === 'installed' && <CapabilitySubpageHeader title={`已安装${items.length ? ` (${items.length})` : ''}`} onBack={() => { setView('market'); setQuery(''); setBatch(false) }} />}
<CapabilityToolbar><Search value={query} onChange={setQuery} />{view === 'market' ? <Button className="sm-topbar-action-btn" aria-label="已安装技能" onClick={() => { setView('installed'); setQuery('') }}><SkillsIcon />已安装{items.length > 0 && <span className="sm-topbar-installed-count">({items.length})</span>}</Button> : <button className="sm-installed-batch-manage" onClick={() => { setBatch(value => !value); setSelected([]) }}>{batch ? '完成' : '批量管理'}</button>}
<div ref={add} className="yc-skill-add"><Button primary className="sm-topbar-action-btn" aria-label="添加技能" aria-expanded={addOpen} onClick={() => setAddOpen(value => !value)}><PlusIcon />添加技能</Button>{addOpen && <div className="wb-popover wb-dropdown yc-skill-add-menu" role="menu" onKeyDown={event => { if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return; event.preventDefault(); const rows = [...event.currentTarget.querySelectorAll<HTMLButtonElement>('button:not(:disabled)')]; const index = rows.indexOf(window.document.activeElement as HTMLButtonElement); rows[event.key === 'Home' ? 0 : event.key === 'End' ? rows.length - 1 : (index + (event.key === 'ArrowUp' ? -1 : 1) + rows.length) % rows.length]?.focus() }}><div className="wb-dropdown__list">{[{ label: '查找技能', action: () => onFindSkill?.() }, { label: '上传技能', action: () => { setImportError(''); setImportOpen(true) } }, { label: '创建技能', action: () => onCreateSkill?.() }].map(row => <button type="button" role="menuitem" className="wb-dropdown__item" key={row.label} disabled={row.label === '查找技能' && !onFindSkill || row.label === '创建技能' && !onCreateSkill} onClick={() => { setAddOpen(false); add.current?.querySelector('button')?.focus(); row.action() }}><span className="wb-dropdown__label">{row.label}</span></button>)}</div></div>}</div>
</CapabilityToolbar>
<div className="yc-skills-scroll">{error && <div className="yc-skill-error" role="alert">{error}<button onClick={() => { void run(reload) }}>重试</button></div>}{notice && <p className="yc-skill-notice" role="status">{notice}</p>}
{view === 'market' ? <><div className="sm-search-panel"><div className="sm-segment-bar"><div className="sm-segment-group" role="tablist" aria-label="技能来源">{['推荐', 'SkillHub', '套件'].map(value => <button key={value} role="tab" aria-selected={category === value} className={'sm-segment-item' + (category === value ? ' sm-segment-item--active' : '')} onClick={() => setCategory(value)}>{value}</button>)}</div></div>
{category === '推荐'
? <section className="sm-installed-section yc-skill-recommendation-section">
<RecommendedSkills cwd={cwd} installed={items} onUseSkill={onUseSkill} onInstalledChange={() => { void reload().catch(reason => { if (mounted.current) setError(String(reason.message ?? reason)) }) }} onViewInstalled={() => { setView('installed'); setQuery('') }} />
</section>
: <div className="sm-empty yc-skill-market-unavailable"><SkillsIcon width={32} height={32} /><p>当前未连接{category}目录服务</p><p>你可以导入本地技能,或查看已安装技能。</p><Button onClick={() => { setView('installed'); setQuery('') }}>查看已安装技能</Button></div>}
</div></> : <section className="sm-installed-section">
{batch && <div className="sm-batch-toolbar"><div className="sm-batch-toolbar-left"><input type="checkbox" aria-label="全选技能" checked={matches.length > 0 && matches.every(item => selected.includes(item.id))} onChange={event => setSelected(event.target.checked ? matches.map(item => item.id) : [])} />已选择 {selected.length} 项</div><div className="sm-batch-toolbar-right"><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, true, cwd) }) }}>启用</Button><Button disabled={busy || !selected.length} onClick={() => { void run(async () => { for (const item of items.filter(value => selected.includes(value.id) && !value.error)) await window.ycode.skills.setEnabled(item.id, false, cwd) }) }}>禁用</Button><Button disabled={busy || !selected.some(id => items.some(item => item.id === id && item.scope === 'user'))} onClick={() => setPendingRemove(items.filter(item => selected.includes(item.id) && item.scope === 'user'))}>卸载</Button></div></div>}
{loading ? <div className="sm-empty" role="status">加载中…</div> : !matches.length ? <div className="sm-empty">{query ? '没有找到相关技能' : '暂无已安装技能'}</div> : <div className="sm-installed-list">{(['workspace', 'user'] as const).map(scope => { const group = matches.filter(item => item.scope === scope); return !!group.length && <section key={scope} className="sm-installed-group"><div className="sm-installed-group-header"><div className="sm-installed-group-title"><span>{scope === 'workspace' ? '空间技能' : '自定义技能'}</span><span className="sm-installed-group-count">({group.length})</span></div></div><div className="sm-grid">{group.map(skill => <div className="yc-skill-card-keyboard" key={skill.id} data-skill-id={skill.id} role={batch ? undefined : 'button'} tabIndex={0} aria-label={`查看 ${skill.name}`} onKeyDown={event => { if (event.target === event.currentTarget && ['Enter', ' '].includes(event.key)) { event.preventDefault(); if (batch) toggleSelected(skill.id); else setSelectedId(skill.id) } }}>
<CardFrame iconUrl={skillAvatarUrl(skill.origin?.recommendationId)} title={skill.name} description={skill.error ? `不可用:${skill.error}` : skill.description} installed installedListMode disabled={!skill.enabled} batchMode={batch} selected={selected.includes(skill.id)} onToggleSelect={() => toggleSelected(skill.id)} onOpen={() => setSelectedId(skill.id)} actions={!batch && <div className="sm-status" onClick={event => event.stopPropagation()}><div className="sm-card-menu-wrap"><button type="button" className="sm-card-menu-btn sm-card-more" aria-label={`更多 ${skill.name}`} aria-expanded={menuId === skill.id} onClick={() => setMenuId(menuId === skill.id ? null : skill.id)}><CardMoreIcon /></button>{menuId === skill.id && <div className="sm-card-menu" role="menu">{!skill.error && skill.userInvocable && onUseSkill && <button className="sm-card-menu-item" onClick={() => { setMenuId(null); useSkill(skill) }}><UseSkillIcon /><span>{skill.enabled ? '去使用' : '启用后使用'}</span></button>}<button className="sm-card-menu-item" onClick={() => { setMenuId(null); setSelectedId(skill.id) }}><DocIcon /><span>查看详情</span></button>{skill.scope === 'user' && <><button className="sm-card-menu-item" disabled={busy || !!skill.error} onClick={() => { setMenuId(null); exportPackage(skill) }}><FolderIcon /><span>导出</span></button><button className="sm-card-menu-item sm-card-menu-item--danger" disabled={busy} onClick={() => { setMenuId(null); setPendingRemove([skill]) }}><TrashIcon /><span>卸载技能</span></button></>}</div>}</div><button type="button" className={'sm-card-pin' + (pinned.includes(skill.id) ? ' sm-card-pin--active' : '')} aria-label={pinned.includes(skill.id) ? '取消置顶' : '置顶'} aria-pressed={pinned.includes(skill.id)} onClick={() => pin(skill.id)}>{pinned.includes(skill.id) ? <UnpinIcon /> : <PinIcon />}</button><span className="sm-card-switch-wrap"><Switch skill={skill} disabled={busy} onToggle={() => toggle(skill)} /></span></div>} />
</div>)}</div></section> })}</div>}
</section>}
</div>
{importOpen && <SkillModalPortal><ImportModal busy={busy} error={importError} onClose={() => setImportOpen(false)} onImport={(overwrite, kind) => { void importPackage(overwrite, kind) }} /></SkillModalPortal>}
{current && <Detail skill={current} document={document} busy={busy} error={detailError || error} onClose={() => setSelectedId(null)} onToggle={() => toggle(current)} onUse={onUseSkill ? () => useSkill(current) : undefined} onExport={() => exportPackage(current)} onRemove={() => setPendingRemove([current])} />}
{pendingRemove.length > 0 && <SkillModalPortal><RemoveConfirm skills={pendingRemove} busy={busy} dialogRef={removeDialog} onClose={() => setPendingRemove([])} onConfirm={() => { void run(async () => { for (const skill of pendingRemove) await window.ycode.skills.remove(skill.id); setPendingRemove([]); setSelectedId(null); setSelected([]); setNotice('已卸载技能副本') }) }} /></SkillModalPortal>}
</div>
}
function documentGlobal(): Document { return window.document }
function readPinned(key: string): string[] { try { const values = JSON.parse(localStorage.getItem(key) ?? '{}')?.pinned; return Array.isArray(values) ? values.filter((id): id is string => typeof id === 'string').slice(0, 200) : [] } catch { return [] } }
function RemoveConfirm({ skills, busy, dialogRef, onClose, onConfirm }: { skills: SkillRecord[]; busy: boolean; dialogRef: React.RefObject<HTMLDivElement | null>; onClose(): void; onConfirm(): void }): React.JSX.Element {
useModalFocus(dialogRef, () => { if (!busy) onClose() })
return <div className="sm-update-confirm-modal-mask"><div className="sm-update-confirm-modal" ref={dialogRef} role="dialog" aria-modal="true" aria-label="确认卸载技能"><div className="sm-update-confirm-modal-header"><span>卸载技能</span><button className="sm-update-confirm-modal-close" aria-label="关闭卸载确认" disabled={busy} onClick={onClose}><CloseIcon /></button></div><div className="sm-update-confirm-modal-content"><p className="sm-update-confirm-description">确定卸载{skills.length === 1 ? `「${skills[0].name}」` : `所选 ${skills.length} 项技能`}?仅删除本机导入副本,保留导入源。</p></div><div className="sm-update-confirm-modal-footer"><Button disabled={busy} onClick={onClose}>取消</Button><Button primary disabled={busy} aria-label="确认卸载" onClick={onConfirm}>{busy ? '正在卸载…' : '卸载'}</Button></div></div></div>
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

@@ -0,0 +1,16 @@
.yc-skill-install-disclosure{padding:16px;background:var(--wb-bg-secondary);border:1px solid var(--wb-border-subtle);border-radius:12px;font-size:13px;line-height:1.65}
.yc-skill-install-disclosure p{margin:8px 0 0;overflow-wrap:anywhere}
.yc-skill-inline-install{display:flex;flex-direction:column;gap:8px;color:var(--wb-color-text-secondary);font-size:13px}
.yc-skill-inline-install progress{width:100%;height:4px;accent-color:var(--wb-color-text-primary)}
.yc-skill-inline-install .yc-skill-error{margin:0}
.yc-skill-source-details{font-size:13px;line-height:1.6;color:var(--wb-color-text-secondary);border-top:1px solid var(--wb-border-subtle);padding-top:16px}
.yc-skill-source-details summary{cursor:pointer;color:var(--wb-color-text-primary);font-weight:500}
.yc-skill-source-details dl{display:grid;grid-template-columns:64px minmax(0,1fr);gap:8px;margin:12px 0 0;padding:16px;background:var(--wb-bg-secondary);border-radius:12px}
.yc-skill-source-details dt{color:var(--wb-color-text-tertiary)}
.yc-skill-source-details dd{margin:0;overflow-wrap:anywhere}
.yc-skill-installed-overview>.markdown-body{margin-top:12px}
.yc-skill-notice.yc-skill-install-success{display:flex;align-items:center;gap:12px;pointer-events:auto}
.yc-skill-install-success .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
.yc-skill-recommendation-meta .yc-skill-recommendation-link{display:inline-flex;align-items:center;gap:4px}
.yc-skill-recommendation-link svg{width:14px;height:14px;flex:none}
.yc-skill-recommendation-link:disabled{cursor:default;opacity:.5}
@@ -0,0 +1,14 @@
.yc-capability-center.yc-skill-modal-host{position:fixed;inset:0;z-index:12000;height:100%;width:100%;display:block;pointer-events:none}
.yc-skill-modal-theme{height:100%;pointer-events:none}
.yc-skill-modal-host .sm-skill-detail-overlay,.yc-skill-modal-host .skill-upload-modal-mask,.yc-skill-modal-host .sm-update-confirm-modal-mask{pointer-events:auto}
.yc-skill-modal-host .yc-skill-detail-shell{display:flex;flex-direction:column;max-height:calc(100dvh - 80px);height:auto;color:var(--wb-color-text-primary);font-family:var(--wb-font-family)}
.yc-skill-detail-shell .sm-skill-detail-header{flex:none}
.yc-skill-detail-scroll{overflow:auto;min-height:0;padding:20px 24px 24px;display:flex;flex-direction:column;gap:20px;overscroll-behavior:contain}
.yc-skill-detail-description p{margin:0;font-size:14px;line-height:1.65;overflow-wrap:anywhere}
.yc-skill-detail-description p.is-collapsed{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.yc-skill-detail-description button{margin-top:8px}
.yc-skill-detail-metadata{display:flex;flex-wrap:wrap;gap:12px 24px;border-top:1px solid var(--wb-border-subtle);border-bottom:1px solid var(--wb-border-subtle);padding:16px 0;font-size:13px;color:var(--wb-color-text-secondary)}
.yc-skill-detail-shell .sm-skill-detail-action-row{flex-wrap:wrap;gap:8px}
.yc-skill-detail-shell .sm-skill-detail-btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;white-space:nowrap}
.yc-skill-detail-shell .yc-skill-description-body{font-size:13px;line-height:1.6;overflow-wrap:anywhere}
@media(max-width:600px){.yc-skill-modal-host .yc-skill-detail-shell{max-height:calc(100dvh - 32px)}.yc-skill-detail-scroll{padding:16px 20px 20px}}
@@ -0,0 +1,37 @@
import { useEffect, useRef } from 'react'
/** Shared focus trap for the recovered source modals. Only the topmost modal reacts to keys. */
const modalStack: HTMLElement[] = []
export function useModalFocus(ref: React.RefObject<HTMLElement | null>, close: () => void): void {
const onClose = useRef(close); onClose.current = close
useEffect(() => {
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null
const element = ref.current
if (element) modalStack.push(element)
ref.current?.querySelector<HTMLElement>('button:not(:disabled),input:not(:disabled)')?.focus()
const keyboard = (event: KeyboardEvent): void => {
if (modalStack.at(-1) !== ref.current) return
if (event.isComposing) return
if (event.key === 'Escape') { event.preventDefault(); event.stopPropagation(); onClose.current() }
if (event.key === 'Tab') {
const targets = [...(ref.current?.querySelectorAll<HTMLElement>('button:not(:disabled),input:not(:disabled),textarea:not(:disabled),select:not(:disabled),summary,[tabindex="0"],a[href]') ?? [])].filter(el => {
if (!el.getClientRects().length || getComputedStyle(el).visibility === 'hidden') return false
// Chromium can retain layout boxes inside a closed details element.
for (let parent = el.parentElement; parent && parent !== ref.current; parent = parent.parentElement) {
if (parent.tagName === 'DETAILS' && !parent.hasAttribute('open') && !parent.querySelector(':scope > summary')?.contains(el)) return false
}
return true
})
const index = targets.indexOf(document.activeElement as HTMLElement)
if (targets.length && (index < 0 || event.shiftKey && index === 0 || !event.shiftKey && index === targets.length - 1)) { event.preventDefault(); targets[event.shiftKey ? targets.length - 1 : 0].focus() }
}
}
document.addEventListener('keydown', keyboard, true)
return () => {
document.removeEventListener('keydown', keyboard, true)
if (element) { const index = modalStack.indexOf(element); if (index >= 0) modalStack.splice(index, 1) }
if (previous?.isConnected) previous.focus()
}
}, [ref])
}
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff. Show more