From a01085c656d0e3b401c9acd4bf50e884c8c77f8d Mon Sep 17 00:00:00 2001 From: "xiaohan.yuan" Date: Wed, 21 Oct 2026 00:04:17 +0800 Subject: [PATCH] feat: ship skill and MCP workflows with signed updater for v0.0.7 --- build/installer.nsh | 56 ++ docs/Claude追加与压缩协议验证-2026-10-20.md | 122 +++ docs/MCP技能与输入区接入-2026-10-20.md | 42 + docs/升级发布说明-2026-10-20.md | 283 ++++++ docs/升级模块落地-2026-10-20.md | 49 + docs/技能详情安装与使用链路修正-2026-10-20.md | 53 ++ docs/接手状态-2026-10-15.md | 14 + docs/推荐技能GitHub安装-2026-10-20.md | 62 ++ docs/推荐技能头像来源-2026-10-20.md | 10 + package-lock.json | 4 +- package.json | 24 +- resources/updates/manifest-contract.md | 41 + resources/updates/public-key.pem | 3 + scripts/build-claude-append-probe.mjs | 11 + scripts/build-claude-kernel-regression.mjs | 4 + scripts/build-request-settings-regression.mjs | 10 + scripts/capability-picker-regression.tsx | 79 ++ scripts/chat-persistence-regression.ts | 3 +- scripts/chat-store-regression.ts | 49 +- scripts/claude-append-protocol-probe.ts | 533 +++++++++++ scripts/claude-capabilities-regression.ts | 199 ++++ scripts/claude-kernel-integration.ts | 82 +- scripts/composer-regression.tsx | 63 +- scripts/context-usage-regression.ts | 73 ++ scripts/input-composition-regression.ts | 75 ++ .../prompt-queue-adversarial-regression.ts | 415 +++++++++ scripts/prompt-queue-regression.ts | 121 +++ .../recommended-skills-backend-regression.ts | 493 ++++++++++ scripts/request-settings-regression.ts | 437 +++++++++ .../run-composer-capabilities-regression.mjs | 26 + scripts/run-recommended-skills-regression.mjs | 14 + scripts/session-store-regression.ts | 3 +- scripts/test-capability-slash-ui.mjs | 88 ++ scripts/test-connectors-page.mjs | 285 ++++++ scripts/test-recommended-archify-package.mjs | 223 +++++ scripts/test-recommended-skills-ui.mjs | 382 ++++++++ scripts/test-skills-ui.mjs | 6 +- scripts/update-backend-regression.ts | 827 +++++++++++++++++ scripts/update-maintenance-regression.ts | 95 ++ scripts/updates/contract-selftest.mjs | 84 ++ scripts/updates/deploy.mjs | 79 ++ scripts/updates/get-exe-version.ps1 | 19 + scripts/updates/init-key.mjs | 81 ++ scripts/updates/lib.mjs | 538 +++++++++++ scripts/updates/nsis-safety-regression.mjs | 35 + scripts/updates/prepare-release.mjs | 71 ++ scripts/updates/provision-server.mjs | 234 +++++ scripts/updates/release-selftest.mjs | 641 +++++++++++++ scripts/updates/release-workflow.mjs | 591 ++++++++++++ scripts/updates/release.mjs | 169 ++++ scripts/updates/selftest.mjs | 372 ++++++++ scripts/updates/transport.mjs | 408 +++++++++ scripts/verify-claude-append-app.cjs | 761 ++++++++++++++++ scripts/verify-recommended-skills-app.cjs | 151 ++++ scripts/verify-updates-installer-marker.cjs | 253 ++++++ scripts/verify-updates-live.cjs | 666 ++++++++++++++ scripts/verify-updates-ui.cjs | 560 ++++++++++++ src/main/agent/claude/claude-kernel.ts | 229 ++++- src/main/agent/queued-request-settings.ts | 24 + src/main/agent/request-settings.ts | 17 + src/main/capabilities/claude-capabilities.ts | 244 +++++ src/main/capabilities/mcp.ts | 61 ++ src/main/capabilities/recommended-skills.ts | 247 +++++ .../capabilities/skill-recommendations.ts | 12 + src/main/capabilities/skills.ts | 112 ++- src/main/chat-turn-service.ts | 151 +++- src/main/context-usage-service.ts | 15 +- src/main/index.ts | 30 + src/main/ipc.ts | 166 +++- src/main/prompt-queue.ts | 802 +++++++++++++++++ src/main/sessions.ts | 14 +- src/main/terminal-host.ts | 4 + src/main/update-lifecycle.ts | 59 ++ src/main/update-maintenance.ts | 21 + src/main/updates/cache.ts | 67 ++ src/main/updates/index.ts | 91 ++ src/main/updates/installer.ts | 205 +++++ src/main/updates/ipc.ts | 103 +++ src/main/updates/logger.ts | 53 ++ src/main/updates/manager.ts | 852 ++++++++++++++++++ src/main/updates/manifest.ts | 194 ++++ src/main/updates/network.ts | 164 ++++ src/main/updates/physical-files.ts | 14 + src/main/updates/semver.ts | 64 ++ src/main/updates/storage.ts | 84 ++ src/main/updates/trust.ts | 53 ++ src/main/updates/types.ts | 110 +++ src/preload/index.ts | 43 + src/renderer/src/App.tsx | 18 +- .../src/capabilities/CapabilityCenter.tsx | 2 +- .../src/capabilities/ConnectorsPage.tsx | 100 +- .../src/capabilities/RecommendedSkills.tsx | 292 ++++++ src/renderer/src/capabilities/SkillAvatar.tsx | 17 + .../src/capabilities/SkillDetailShell.tsx | 34 + .../src/capabilities/SkillModalPortal.tsx | 49 + src/renderer/src/capabilities/SkillsPage.tsx | 124 ++- .../assets/archify-owner-avatar.jpg | Bin 0 -> 9461 bytes .../src/capabilities/recommended-flow.css | 16 + .../src/capabilities/skill-detail-shell.css | 14 + .../src/capabilities/skill-modal-focus.ts | 37 + src/renderer/src/capabilities/skills-host.css | 18 + .../src/capabilities/skills-source.d.mts | 2 +- .../src/components/AboutHelpSettingsPanel.tsx | 39 +- src/renderer/src/components/ChatPane.tsx | 465 +++++++++- src/renderer/src/components/Sidebar.tsx | 4 +- .../src/components/shortcuts-catalog.ts | 1 - .../src/components/task-confirm-dialog.tsx | 9 +- .../components/updates/UpdateSidebarCard.tsx | 191 ++++ .../src/components/updates/update-ui-model.ts | 105 +++ .../src/components/updates/update-ui-store.ts | 243 +++++ .../src/components/updates/updates-ui.css | 286 ++++++ src/renderer/src/stores/chat.ts | 104 ++- .../workbuddy/composer/CapabilityPicker.tsx | 262 ++++++ .../workbuddy/composer/ComposerControls.tsx | 108 ++- .../workbuddy/composer/PromptQueuePanel.tsx | 89 ++ .../workbuddy/composer/capability-picker.css | 213 +++++ .../composer/capability-selection.ts | 26 + .../src/workbuddy/composer/composer.css | 16 + .../context-info/ComposerContextInfo.tsx | 18 +- .../workbuddy/composer/draft-fingerprint.ts | 108 +++ .../workbuddy/composer/input-composition.ts | 206 +++++ .../permission/SourcePermissionControl.tsx | 2 +- .../src/workbuddy/composer/prompt-queue.css | 28 + .../composer/useCapabilityCatalog.ts | 29 + .../conversation/UserMessageEditor.tsx | 6 +- .../conversation/message-capabilities.ts | 16 + .../references/ReferenceContentEditor.tsx | 29 +- .../src/workbuddy/user-menu/AccountMenu.tsx | 43 +- .../src/workbuddy/workbench/escape-action.ts | 3 +- src/shared/capabilities.ts | 42 + src/shared/context-usage.ts | 2 +- src/shared/ipc.ts | 11 + src/shared/prompt-queue.ts | 28 + src/shared/types.ts | 5 +- src/shared/updates.ts | 25 + src/shared/workspaces.ts | 1 + 136 files changed, 18646 insertions(+), 217 deletions(-) create mode 100644 build/installer.nsh create mode 100644 docs/Claude追加与压缩协议验证-2026-10-20.md create mode 100644 docs/MCP技能与输入区接入-2026-10-20.md create mode 100644 docs/升级发布说明-2026-10-20.md create mode 100644 docs/升级模块落地-2026-10-20.md create mode 100644 docs/技能详情安装与使用链路修正-2026-10-20.md create mode 100644 docs/推荐技能GitHub安装-2026-10-20.md create mode 100644 docs/推荐技能头像来源-2026-10-20.md create mode 100644 resources/updates/manifest-contract.md create mode 100644 resources/updates/public-key.pem create mode 100644 scripts/build-claude-append-probe.mjs create mode 100644 scripts/build-request-settings-regression.mjs create mode 100644 scripts/capability-picker-regression.tsx create mode 100644 scripts/claude-append-protocol-probe.ts create mode 100644 scripts/claude-capabilities-regression.ts create mode 100644 scripts/context-usage-regression.ts create mode 100644 scripts/input-composition-regression.ts create mode 100644 scripts/prompt-queue-adversarial-regression.ts create mode 100644 scripts/prompt-queue-regression.ts create mode 100644 scripts/recommended-skills-backend-regression.ts create mode 100644 scripts/request-settings-regression.ts create mode 100644 scripts/run-composer-capabilities-regression.mjs create mode 100644 scripts/run-recommended-skills-regression.mjs create mode 100644 scripts/test-capability-slash-ui.mjs create mode 100644 scripts/test-connectors-page.mjs create mode 100644 scripts/test-recommended-archify-package.mjs create mode 100644 scripts/test-recommended-skills-ui.mjs create mode 100644 scripts/update-backend-regression.ts create mode 100644 scripts/update-maintenance-regression.ts create mode 100644 scripts/updates/contract-selftest.mjs create mode 100644 scripts/updates/deploy.mjs create mode 100644 scripts/updates/get-exe-version.ps1 create mode 100644 scripts/updates/init-key.mjs create mode 100644 scripts/updates/lib.mjs create mode 100644 scripts/updates/nsis-safety-regression.mjs create mode 100644 scripts/updates/prepare-release.mjs create mode 100644 scripts/updates/provision-server.mjs create mode 100644 scripts/updates/release-selftest.mjs create mode 100644 scripts/updates/release-workflow.mjs create mode 100644 scripts/updates/release.mjs create mode 100644 scripts/updates/selftest.mjs create mode 100644 scripts/updates/transport.mjs create mode 100644 scripts/verify-claude-append-app.cjs create mode 100644 scripts/verify-recommended-skills-app.cjs create mode 100644 scripts/verify-updates-installer-marker.cjs create mode 100644 scripts/verify-updates-live.cjs create mode 100644 scripts/verify-updates-ui.cjs create mode 100644 src/main/agent/queued-request-settings.ts create mode 100644 src/main/agent/request-settings.ts create mode 100644 src/main/capabilities/claude-capabilities.ts create mode 100644 src/main/capabilities/recommended-skills.ts create mode 100644 src/main/capabilities/skill-recommendations.ts create mode 100644 src/main/prompt-queue.ts create mode 100644 src/main/update-lifecycle.ts create mode 100644 src/main/update-maintenance.ts create mode 100644 src/main/updates/cache.ts create mode 100644 src/main/updates/index.ts create mode 100644 src/main/updates/installer.ts create mode 100644 src/main/updates/ipc.ts create mode 100644 src/main/updates/logger.ts create mode 100644 src/main/updates/manager.ts create mode 100644 src/main/updates/manifest.ts create mode 100644 src/main/updates/network.ts create mode 100644 src/main/updates/physical-files.ts create mode 100644 src/main/updates/semver.ts create mode 100644 src/main/updates/storage.ts create mode 100644 src/main/updates/trust.ts create mode 100644 src/main/updates/types.ts create mode 100644 src/renderer/src/capabilities/RecommendedSkills.tsx create mode 100644 src/renderer/src/capabilities/SkillAvatar.tsx create mode 100644 src/renderer/src/capabilities/SkillDetailShell.tsx create mode 100644 src/renderer/src/capabilities/SkillModalPortal.tsx create mode 100644 src/renderer/src/capabilities/assets/archify-owner-avatar.jpg create mode 100644 src/renderer/src/capabilities/recommended-flow.css create mode 100644 src/renderer/src/capabilities/skill-detail-shell.css create mode 100644 src/renderer/src/capabilities/skill-modal-focus.ts create mode 100644 src/renderer/src/components/updates/UpdateSidebarCard.tsx create mode 100644 src/renderer/src/components/updates/update-ui-model.ts create mode 100644 src/renderer/src/components/updates/update-ui-store.ts create mode 100644 src/renderer/src/components/updates/updates-ui.css create mode 100644 src/renderer/src/workbuddy/composer/CapabilityPicker.tsx create mode 100644 src/renderer/src/workbuddy/composer/PromptQueuePanel.tsx create mode 100644 src/renderer/src/workbuddy/composer/capability-picker.css create mode 100644 src/renderer/src/workbuddy/composer/capability-selection.ts create mode 100644 src/renderer/src/workbuddy/composer/draft-fingerprint.ts create mode 100644 src/renderer/src/workbuddy/composer/input-composition.ts create mode 100644 src/renderer/src/workbuddy/composer/prompt-queue.css create mode 100644 src/renderer/src/workbuddy/composer/useCapabilityCatalog.ts create mode 100644 src/renderer/src/workbuddy/conversation/message-capabilities.ts create mode 100644 src/shared/prompt-queue.ts create mode 100644 src/shared/updates.ts diff --git a/build/installer.nsh b/build/installer.nsh new file mode 100644 index 0000000..3ce8f96 --- /dev/null +++ b/build/installer.nsh @@ -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 diff --git a/docs/Claude追加与压缩协议验证-2026-10-20.md b/docs/Claude追加与压缩协议验证-2026-10-20.md new file mode 100644 index 0000000..6298b94 --- /dev/null +++ b/docs/Claude追加与压缩协议验证-2026-10-20.md @@ -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 完成并记录证据。 +- 原生追加与压缩混发被产品入口拒绝;真实网络故障、工具密集追加、多平台进程崩溃注入仍需要进一步验证。 diff --git a/docs/MCP技能与输入区接入-2026-10-20.md b/docs/MCP技能与输入区接入-2026-10-20.md new file mode 100644 index 0000000..159080d --- /dev/null +++ b/docs/MCP技能与输入区接入-2026-10-20.md @@ -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、不发布、不安装、不重启用户安装版;保留工作区已有升级模块等修改。 diff --git a/docs/升级发布说明-2026-10-20.md b/docs/升级发布说明-2026-10-20.md new file mode 100644 index 0000000..ae90fc8 --- /dev/null +++ b/docs/升级发布说明-2026-10-20.md @@ -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)、无查询、无片段、无凭据,并精确位于 + `/versions//` 下。 +- 客户端只接受与清单同源的安装包 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//`。 +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 运行时探针没有执行;安装器写入、覆盖、重启和卸载行为仍是测试缺口。 diff --git a/docs/升级模块落地-2026-10-20.md b/docs/升级模块落地-2026-10-20.md new file mode 100644 index 0000000..651e5dc --- /dev/null +++ b/docs/升级模块落地-2026-10-20.md @@ -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。 diff --git a/docs/技能详情安装与使用链路修正-2026-10-20.md b/docs/技能详情安装与使用链路修正-2026-10-20.md new file mode 100644 index 0000000..3c8ca19 --- /dev/null +++ b/docs/技能详情安装与使用链路修正-2026-10-20.md @@ -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` 隔离数据,未操作用户技能库、执行技能脚本或请求模型。保留既有修改,未提交、发布或重启用户安装版。 diff --git a/docs/接手状态-2026-10-15.md b/docs/接手状态-2026-10-15.md index 5f40b07..f32d4cb 100644 --- a/docs/接手状态-2026-10-15.md +++ b/docs/接手状态-2026-10-15.md @@ -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 级沙盒;此前所有沙盒验收结果只作为历史记录,不代表当前代码能力。 diff --git a/docs/推荐技能GitHub安装-2026-10-20.md b/docs/推荐技能GitHub安装-2026-10-20.md new file mode 100644 index 0000000..bf4423f --- /dev/null +++ b/docs/推荐技能GitHub安装-2026-10-20.md @@ -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 进程以加载修复,未重启安装版、修改系统代理或改动用户技能。 diff --git a/docs/推荐技能头像来源-2026-10-20.md b/docs/推荐技能头像来源-2026-10-20.md new file mode 100644 index 0000000..3d8f229 --- /dev/null +++ b/docs/推荐技能头像来源-2026-10-20.md @@ -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` 为受限资源标识,不接受任意本地路径。 diff --git a/package-lock.json b/package-lock.json index f795f05..cbf21ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6bb23a4..138cc61 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/resources/updates/manifest-contract.md b/resources/updates/manifest-contract.md new file mode 100644 index 0000000..89c1cd8 --- /dev/null +++ b/resources/updates/manifest-contract.md @@ -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 + `/versions//.exe`. +- The client and release tooling require the artifact origin to match the manifest origin. +- `latest.json` is served at `/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/` diff --git a/resources/updates/public-key.pem b/resources/updates/public-key.pem new file mode 100644 index 0000000..d019153 --- /dev/null +++ b/resources/updates/public-key.pem @@ -0,0 +1,3 @@ +-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAxUeHhwDpkjDtIDI9EliFgdmMjahN5Mgq/2cX2+gLkKY= +-----END PUBLIC KEY----- diff --git a/scripts/build-claude-append-probe.mjs b/scripts/build-claude-append-probe.mjs new file mode 100644 index 0000000..214dd3d --- /dev/null +++ b/scripts/build-claude-append-probe.mjs @@ -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' +}) \ No newline at end of file diff --git a/scripts/build-claude-kernel-regression.mjs b/scripts/build-claude-kernel-regression.mjs index 5b4be8b..fd9f5f3 100644 --- a/scripts/build-claude-kernel-regression.mjs +++ b/scripts/build-claude-kernel-regression.mjs @@ -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 }; ` })) } diff --git a/scripts/build-request-settings-regression.mjs b/scripts/build-request-settings-regression.mjs new file mode 100644 index 0000000..d38de9b --- /dev/null +++ b/scripts/build-request-settings-regression.mjs @@ -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' +}) diff --git a/scripts/capability-picker-regression.tsx b/scripts/capability-picker-regression.tsx new file mode 100644 index 0000000..bc763e2 --- /dev/null +++ b/scripts/capability-picker-regression.tsx @@ -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( + 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( undefined} + onClose={() => undefined} query="compact" />).includes('data-kind="command"'), true) +check('SSR renders explicit empty state', + renderToStaticMarkup( undefined} + onClose={() => undefined} query="missing" />).includes('没有匹配的能力'), true) + +console.log(JSON.stringify({ result: 'PASS', checks: checks.length })) diff --git a/scripts/chat-persistence-regression.ts b/scripts/chat-persistence-regression.ts index 8da17bf..8a26ecb 100644 --- a/scripts/chat-persistence-regression.ts +++ b/scripts/chat-persistence-regression.ts @@ -124,7 +124,8 @@ async function main(): Promise { 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 {} diff --git a/scripts/chat-store-regression.ts b/scripts/chat-store-regression.ts index ce0c390..3fe2a8f 100644 --- a/scripts/chat-store-regression.ts +++ b/scripts/chat-store-regression.ts @@ -92,7 +92,7 @@ function resetStore(): void { }) } -function installFakeWindow(send: (payload: { sessionId: string; text: string }) => Promise): void { +function installFakeWindow(send: (payload: Parameters[0]) => Promise): void { const api = { session: { list: async () => [session], @@ -718,7 +718,7 @@ async function testEarlyOrderedEventsAndDuplicateGuard(): Promise { 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 { assert.equal(useChatStore.getState().messages[0].content, 'fast', 'stale session fetch cannot replace a newer selection') } +async function testNativeSegmentBoundaryBeforeSendResponse(): Promise { + resetStore() + const gate = deferred() + 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 { + await testNativeSegmentBoundaryBeforeSendResponse() resetStore() let inlinePayload: Parameters[0] | undefined installFakeWindow(async payload => { inlinePayload = payload; return sendResult('inline-layout') }) @@ -947,6 +972,26 @@ async function main(): Promise { 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[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() + 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() diff --git a/scripts/claude-append-protocol-probe.ts b/scripts/claude-append-protocol-probe.ts new file mode 100644 index 0000000..1664c9b --- /dev/null +++ b/scripts/claude-append-protocol-probe.ts @@ -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 + body: Record + userTexts: string[] + latestText: string + completed: boolean + aborted: boolean + responseText?: string +} + +interface Deferred { + promise: Promise + resolve(value: T): void +} + +class AsyncQueue implements AsyncIterable { + private readonly values: T[] = [] + private readonly waiters: Array<(result: IteratorResult) => 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 { + 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>((resolve) => this.waiters.push(resolve)) + } + } + } +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((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[] { + 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 + 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 + if (item.type === 'text' && typeof item.text === 'string') texts.push(item.text) + } + } + return texts +} + +function requestTrace(body: Record): Array> { + const messages = Array.isArray(body.messages) ? body.messages : [] + return messages.flatMap((rawMessage) => { + if (!rawMessage || typeof rawMessage !== 'object') return [] + const message = rawMessage as Record + 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 + 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 { + const value = message as unknown as Record + const inner = value.message && typeof value.message === 'object' + ? value.message as Record + : 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).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(description: string, predicate: () => T | undefined | false, timeoutMs = 20_000): Promise { + 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() +const processAbort = new AbortController() +const server = createServer((req, res) => { + let raw = '' + req.on('data', (chunk) => { raw += chunk }) + req.on('end', async () => { + let body: Record = {} + try { body = JSON.parse(raw || '{}') as Record } 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((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() +let instance: ReturnType | 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 | 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) + const assistants = runtimeMessages + .filter((message) => message.type === 'assistant') + .map((message) => message as unknown as Record) + const userFrames = runtimeMessages + .filter((message) => message.type === 'user') + .map((message) => message as unknown as Record) + + 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 | 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 + 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((resolve) => server.close(() => resolve())) + await rm(tempRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }).catch(() => undefined) +} \ No newline at end of file diff --git a/scripts/claude-capabilities-regression.ts b/scripts/claude-capabilities-regression.ts new file mode 100644 index 0000000..65fb7b9 --- /dev/null +++ b/scripts/claude-capabilities-regression.ts @@ -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 { + 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 { + 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, 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((_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(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 })) diff --git a/scripts/claude-kernel-integration.ts b/scripts/claude-kernel-integration.ts index 026f7f5..18ebe2b 100644 --- a/scripts/claude-kernel-integration.ts +++ b/scripts/claude-kernel-integration.ts @@ -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> = [] + const target = resumed as unknown as { handleMessage(message: unknown): Promise } + const original = target.handleMessage.bind(target) + target.handleMessage = message => { observed.push(message as Record); 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 { diff --git a/scripts/composer-regression.tsx b/scripts/composer-regression.tsx index d6c7614..89cf9cd 100644 --- a/scripts/composer-regression.tsx +++ b/scripts/composer-regression.tsx @@ -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: '&\u200B' }) assert.equal(userClipboard.plainText, '&', 'user text/plain omits the source zero-width marker') assert.equal(userClipboard.html, '<plain>&\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( undefined} onSend={() => undefined} onStop={() => undefined} />) -assert.match(activeRunMarkup, /aria-label="停止"/) +assert.match(activeRunMarkup, /aria-label="停止当前运行"/) +assert.match(activeRunMarkup, /aria-label="发送"/) +const emptyActiveRunMarkup = renderToStaticMarkup( undefined} onSend={() => undefined} onStop={() => undefined} />) +assert.match(emptyActiveRunMarkup, /aria-label="停止"/) +assert.doesNotMatch(emptyActiveRunMarkup, /aria-label="发送"/) assert.doesNotMatch(activeRunMarkup, /]*disabled/) console.log('Composer/message action regression cases passed') diff --git a/scripts/context-usage-regression.ts b/scripts/context-usage-regression.ts new file mode 100644 index 0000000..2dd6273 --- /dev/null +++ b/scripts/context-usage-regression.ts @@ -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 => ({ + 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 = {}) { + 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') diff --git a/scripts/input-composition-regression.ts b/scripts/input-composition-regression.ts new file mode 100644 index 0000000..38780b9 --- /dev/null +++ b/scripts/input-composition-regression.ts @@ -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') diff --git a/scripts/prompt-queue-adversarial-regression.ts b/scripts/prompt-queue-adversarial-regression.ts new file mode 100644 index 0000000..87edbea --- /dev/null +++ b/scripts/prompt-queue-adversarial-regression.ts @@ -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 => + ({ sessionId, text, ...extra }) + +async function waitFor(check: () => boolean | Promise, label: string): Promise { + 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): Promise { + 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 + 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(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 | 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 diff --git a/scripts/prompt-queue-regression.ts b/scripts/prompt-queue-regression.ts new file mode 100644 index 0000000..21c3bb0 --- /dev/null +++ b/scripts/prompt-queue-regression.ts @@ -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() + const immediate = createPromptQueue({ ...options, file: join(root, 'immediate.json'), blocked: async () => true, + deliverNow: (_payload, id) => new Promise((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 }) +} diff --git a/scripts/recommended-skills-backend-regression.ts b/scripts/recommended-skills-backend-regression.ts new file mode 100644 index 0000000..7a678d3 --- /dev/null +++ b/scripts/recommended-skills-backend-regression.ts @@ -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).__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 { + userData = path.join(testRoot, `data-${name}`) + await fs.mkdir(userData, { recursive: true }) + ;(globalThis as Record).__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 { + 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 = {}): 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 = { + '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 { + 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 } = {}): 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 + +/** A response that never completes until the caller's abort signal fires. */ +function stallingResponse(totalBytes: number, chunk: Uint8Array, init?: RequestInit): Response { + let streamController: ReadableStreamDefaultController | undefined + let timer: ReturnType | 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(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(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' })) diff --git a/scripts/request-settings-regression.ts b/scripts/request-settings-regression.ts new file mode 100644 index 0000000..9cf3af8 --- /dev/null +++ b/scripts/request-settings-regression.ts @@ -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 { + promise: Promise + resolve(value: T): void + reject(reason?: unknown): void +} + +interface FakeBackend { + api: FakeApi + sessions: SessionMeta[] + messages: Record + replaceSession(session: SessionMeta): void +} + +interface FakeApi { + session: { + list(): Promise + create(workspaceId: string | undefined, context?: SessionContext): Promise + messages(sessionId: string): Promise + updateContext(sessionId: string, patch: SessionContext): Promise + remove(sessionId: string, options?: { onlyIfEmpty?: boolean }): Promise + rename(sessionId: string, title: string): Promise + } + chat: { + send(payload: SendPayload): Promise + 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 + onEvent(listener: (event: ChatEvent) => void): () => void + onStarted(listener: (run: ActiveChatRun) => void): () => void + queue: { + get(sessionId: string): Promise<{ items: unknown[] }> + enqueue(payload: SendPayload): Promise + } + } +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + +async function flushAsync(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +async function waitFor(description: string, predicate: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (predicate()) return + await new Promise((resolve) => setTimeout(resolve, 5)) + } + throw new Error(`timed out waiting for ${description}`) +} + +function makeSession(id: string, overrides: Partial = {}): 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 = {} +): 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 { + 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 }> = [] + backend.api.session.updateContext = (sessionId, patch) => { + const gate = deferred() + 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 { + 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 } }).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 { + 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() + 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 { + resetStore([], null, [], { modelId: 'draft-a', thinkingLevel: 'low' }) + const backend = installFakeBackend([], {}) + const createGate = deferred() + const updateGate = deferred() + 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 +} diff --git a/scripts/run-composer-capabilities-regression.mjs b/scripts/run-composer-capabilities-regression.mjs new file mode 100644 index 0000000..e0e6280 --- /dev/null +++ b/scripts/run-composer-capabilities-regression.mjs @@ -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) +} diff --git a/scripts/run-recommended-skills-regression.mjs b/scripts/run-recommended-skills-regression.mjs new file mode 100644 index 0000000..d0be19d --- /dev/null +++ b/scripts/run-recommended-skills-regression.mjs @@ -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) diff --git a/scripts/session-store-regression.ts b/scripts/session-store-regression.ts index 052d07f..4cea951 100644 --- a/scripts/session-store-regression.ts +++ b/scripts/session-store-regression.ts @@ -121,7 +121,8 @@ async function main(): Promise { 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 {} diff --git a/scripts/test-capability-slash-ui.mjs b/scripts/test-capability-slash-ui.mjs new file mode 100644 index 0000000..4afcb17 --- /dev/null +++ b/scripts/test-capability-slash-ui.mjs @@ -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
({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')}} />
}createRoot(document.getElementById('root')).render();`) + 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'), '
') + 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) }) +} diff --git a/scripts/test-connectors-page.mjs b/scripts/test-connectors-page.mjs new file mode 100644 index 0000000..b28df87 --- /dev/null +++ b/scripts/test-connectors-page.mjs @@ -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(
{}} />
) +`) + 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'), + '
') + + 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) }) diff --git a/scripts/test-recommended-archify-package.mjs b/scripts/test-recommended-archify-package.mjs new file mode 100644 index 0000000..2a67faf --- /dev/null +++ b/scripts/test-recommended-archify-package.mjs @@ -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(' { + 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 })) diff --git a/scripts/test-recommended-skills-ui.mjs b/scripts/test-recommended-skills-ui.mjs new file mode 100644 index 0000000..2a90320 --- /dev/null +++ b/scripts/test-recommended-skills-ui.mjs @@ -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( +
+
{}} onUseSkill={skill=>window.__qa.uses.push(skill)} onCreateSkill={()=>window.__qa.created++} onFindSkill={()=>window.__qa.find++}/> +
);`) + 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'), '
') + + 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) }) +} diff --git a/scripts/test-skills-ui.mjs b/scripts/test-skills-ui.mjs index 6ec76ac..f9ceab2 100644 --- a/scripts/test-skills-ui.mjs +++ b/scripts/test-skills-ui.mjs @@ -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(
{}} onUseSkill={skill=>window.__skillsQa.uses.push(skill)} onCreateSkill={()=>window.__skillsQa.created++} onFindSkill={()=>window.__skillsQa.find++}/>
);`) - 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'),'
') 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) diff --git a/scripts/update-backend-regression.ts b/scripts/update-backend-regression.ts new file mode 100644 index 0000000..c921b12 --- /dev/null +++ b/scripts/update-backend-regression.ts @@ -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]> = [] +const test = (name: string, run: () => Promise | void): void => { + tests.push([name, run]) +} + +const sleep = (milliseconds: number): Promise => new Promise(resolve => setTimeout(resolve, milliseconds)) + +async function waitFor(predicate: () => boolean | Promise, timeout = 5_000): Promise { + 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 + requests: string[] + downloadRanges: Array + redirect: 'none' | 'same-origin' | 'cross-origin' + crossOrigin: string + oversizeManifest: boolean + interruptNextDownload: boolean + chunkSize: number + chunkDelayMs: number + downloadStarted: number + origin: string + close: () => Promise +} + +async function sendBody(response: ServerResponse, body: Buffer, fixture: HttpFixture): Promise { + 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(resolve => response.once('drain', resolve)) + if (fixture.chunkDelayMs > 0) await sleep(fixture.chunkDelayMs) + } + if (!response.destroyed) response.end() +} + +async function createHttpFixture(): Promise { + let server: ReturnType + 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((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((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) | null = null + + async validateEnvironment(_requiredBytes?: number): Promise { + 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 { + this.verifyCount += 1 + } + + async launch(_installer: CachedInstaller, _environment: InstallEnvironment): Promise { + 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 | 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 + 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 { + 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() diff --git a/scripts/update-maintenance-regression.ts b/scripts/update-maintenance-regression.ts new file mode 100644 index 0000000..110fe0f --- /dev/null +++ b/scripts/update-maintenance-regression.ts @@ -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(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(resolve => { directQuiesceStarted = resolve }) +let directQuiesceCalls = 0 +const directLifecycle = createUpdateLifecycle({ + quiesce: () => { + directQuiesceCalls += 1 + directQuiesceStarted() + return new Promise(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(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(() => 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') diff --git a/scripts/updates/contract-selftest.mjs b/scripts/updates/contract-selftest.mjs new file mode 100644 index 0000000..b4cb1ab --- /dev/null +++ b/scripts/updates/contract-selftest.mjs @@ -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 +}) diff --git a/scripts/updates/deploy.mjs b/scripts/updates/deploy.mjs new file mode 100644 index 0000000..0a211fc --- /dev/null +++ b/scripts/updates/deploy.mjs @@ -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 --installer + [--transport ssh|local] [--base-url ] [--public-key ] + [--ssh-host local001] [--local-root ] [--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)) diff --git a/scripts/updates/get-exe-version.ps1 b/scripts/updates/get-exe-version.ps1 new file mode 100644 index 0000000..dc55d3b --- /dev/null +++ b/scripts/updates/get-exe-version.ps1 @@ -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" diff --git a/scripts/updates/init-key.mjs b/scripts/updates/init-key.mjs new file mode 100644 index 0000000..b63073e --- /dev/null +++ b/scripts/updates/init-key.mjs @@ -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 ] [--public-key ] [--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)) diff --git a/scripts/updates/lib.mjs b/scripts/updates/lib.mjs new file mode 100644 index 0000000..b8f02df --- /dev/null +++ b/scripts/updates/lib.mjs @@ -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('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +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 ` + + + + + + + + YCode v${version} 下载 + + + +
+

YCode / Windows x64

+

v${version}

+

此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。

+
+
+
+ 发布时间 ${publishedAt} + ${size} +
+ 下载 Windows x64 安装包 + SHA-256 ${sha256} +
+
+
+

更新说明

+

${zhNotes}

+
+
+

Release notes

+

${enNotes}

+
+
+
+ +
+ + +` +} + +export function renderNoReleasePage() { + return ` + + + + + + + YCode 下载 + + +

YCode

当前还没有发布正式安装包。此静态页不加载脚本、不提供占位版本;发布完成后再刷新。

+ +` +} + +export function usageError(usage, error) { + console.error(`${error.message}\n\n${usage}`) + process.exitCode = 2 +} diff --git a/scripts/updates/nsis-safety-regression.mjs b/scripts/updates/nsis-safety-regression.mjs new file mode 100644 index 0000000..769f51f --- /dev/null +++ b/scripts/updates/nsis-safety-regression.mjs @@ -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.') diff --git a/scripts/updates/prepare-release.mjs b/scripts/updates/prepare-release.mjs new file mode 100644 index 0000000..ceabad8 --- /dev/null +++ b/scripts/updates/prepare-release.mjs @@ -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 --version + --notes-zh --notes-en [--base-url ] [--published-at ] + [--private-key ] [--public-key ] [--output ] + +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)) diff --git a/scripts/updates/provision-server.mjs b/scripts/updates/provision-server.mjs new file mode 100644 index 0000000..3f801d6 --- /dev/null +++ b/scripts/updates/provision-server.mjs @@ -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)) diff --git a/scripts/updates/release-selftest.mjs b/scripts/updates/release-selftest.mjs new file mode 100644 index 0000000..17ce28d --- /dev/null +++ b/scripts/updates/release-selftest.mjs @@ -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 +}) diff --git a/scripts/updates/release-workflow.mjs b/scripts/updates/release-workflow.mjs new file mode 100644 index 0000000..e183111 --- /dev/null +++ b/scripts/updates/release-workflow.mjs @@ -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}`) + } + } +} diff --git a/scripts/updates/release.mjs b/scripts/updates/release.mjs new file mode 100644 index 0000000..29c65c5 --- /dev/null +++ b/scripts/updates/release.mjs @@ -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 [--notes-zh ] [--notes-en ] + npm run release -- --resume --version + npm run release -- --help + +Options: + --version Stable Windows x64 version. Prompted when omitted. + --notes-zh Chinese release notes. Defaults to "YCode 更新". + --notes-en English release notes. Defaults to "YCode update". + --resume Reuse an existing installer and signed manifest. + --ssh-host SSH host, default local001. + --base-url Release base URL, default ${DEFAULT_BASE_URL}. + --private-key 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 +}) diff --git a/scripts/updates/selftest.mjs b/scripts/updates/selftest.mjs new file mode 100644 index 0000000..49906ec --- /dev/null +++ b/scripts/updates/selftest.mjs @@ -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 = '\n第二行' + payload.notes.en = '' + const html = renderDownloadPage(payload, { baseUrl }) + assert.doesNotMatch(html, /