From 40e10ed8c55ec3f3394f3716f3c5f9baa9eb7bc1 Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Sun, 18 Oct 2026 12:20:15 +0800 Subject: [PATCH 1/7] Ship Desktop workbenches with verified native AI and bundled delivery tooling --- .../release-notes/desktop-v3.0.0-alpha.1.md | 31 ++++++++ .github/workflows/ci.yml | 33 +++------ AGENTS.md | 4 +- apps/desktop/README.i18n.yaml | 4 +- apps/desktop/README.md | 19 ++--- apps/desktop/README.zh.md | 19 ++--- apps/desktop/scripts/pack-workdsh-profile.mjs | 4 +- .../prepare-workdsh-primary-runtime.mjs | 5 ++ .../scripts/prepare-workdsh-runtime.mjs | 8 +- .../verify-product-plugin-inventory.mjs | 2 +- .../scripts/verify-profile-release.test.mjs | 26 +++++-- .../desktop/scripts/verify-workdsh-carrier.ts | 8 +- .../scripts/workdsh-package-boundary.mjs | 3 + .../tests/native-workbench-frame.spec.ts | 29 ++++++++ apps/web/AGENTS.md | 6 +- .../scripts/native-workbench-frame-patch.mjs | 39 ++-------- apps/web/scripts/probe-runtime-upgrade.mjs | 2 +- .../native-workbench-frame.test.mjs | 3 + .../native-workbench-frame-patch.test.mjs | 29 +++++++- .../references/first-development.md | 4 +- .../references/startup-validation.md | 2 +- packages/plugins/workbenches/README.md | 14 ++-- .../workbenches/scripts/run-delivery.mjs | 74 +++++++++++++++---- .../src/runtime/development-starter.ts | 8 +- .../src/runtime/development-timing.ts | 2 +- .../workbenches/src/runtime/sdk-metadata.ts | 2 +- .../src/runtime/source-checkpoint.ts | 7 +- .../tests/delivery-runner.test.mjs | 60 ++++++++++++++- .../tests/development-starter.test.mjs | 2 +- .../tests/development-timing.test.mjs | 2 +- .../workbenches/tests/sdk-metadata.test.mjs | 2 +- .../tests/source-checkpoint.test.mjs | 11 +++ scripts/ci-scope.test.mjs | 72 ++++++++++++++++++ scripts/native-workbench-frame-patch.d.mts | 5 ++ .../native-workbench-frame-patch.json | 0 scripts/native-workbench-frame-patch.mjs | 46 ++++++++++++ 36 files changed, 451 insertions(+), 136 deletions(-) create mode 100644 .github/release-notes/desktop-v3.0.0-alpha.1.md create mode 100644 apps/desktop/tests/native-workbench-frame.spec.ts create mode 100644 apps/web/tests/integration/native-workbench-frame.test.mjs create mode 100644 scripts/ci-scope.test.mjs create mode 100644 scripts/native-workbench-frame-patch.d.mts rename {apps/web/scripts => scripts}/native-workbench-frame-patch.json (100%) create mode 100644 scripts/native-workbench-frame-patch.mjs diff --git a/.github/release-notes/desktop-v3.0.0-alpha.1.md b/.github/release-notes/desktop-v3.0.0-alpha.1.md new file mode 100644 index 0000000000..f4d68784b2 --- /dev/null +++ b/.github/release-notes/desktop-v3.0.0-alpha.1.md @@ -0,0 +1,31 @@ +# WorkDSH Desktop 3.0.0-alpha.1 + +每个人,都能定制自己的 AI 服务。 + +本次 Alpha 更新统一升级至官方 **DeepSeek Harness 0.2.1-rc.1**,并改进桌面运行诊断、工作台共用交付流程与发行校验。README 顶部提供完整系统实录,包含中文配音、字幕及开源地址。 + +## 更新内容 + +- Desktop 与 Web 固定到同一 DSH 版本。官方运行依赖从校验过的发行文件提取,桌面端继续使用完整官方客户端。 +- 桌面启动失败时显示实际原因与脱敏后的近期运行输出,并保留本机错误记录;进程清理失败不会再覆盖启动原因。 +- 基础 Desktop Profile 加入工作台平台,与连接器、专家、资料库、项目和 Skill 共六个默认 WorkDSH bundle。工作台统一需求确认、开发、启动检查、安装状态核对与试用验收,记录阶段耗时,支持中断后恢复。 +- 视频展示使用平台生成的旅行应用,可持续管理多次旅行,每次有独立行程、预算和清单;基础安装包不预装该业务示例。 +- 改进发行脚本的首次依赖安装顺序,检查 Profile 依赖、平台运行文件与归档完整性。 +- 企业连接继续作为独立插件下载并显式安装,基础安装包不预装企业账号与协作插件。 + +## 发行文件 + +- Windows x64:`WorkDSH-3.0.0-alpha.1-x64-Setup.exe` 安装程序。 +- macOS Apple Silicon arm64 / Intel x64:对应架构的 `WorkDSH-3.0.0-alpha.1-.dmg`。本次 DMG 未签名。 +- 企业连接:`workdsh-enterprise-connection-0.1.0-alpha.2.tgz`,另附 ZIP、兼容版本清单及校验信息;在“插件 → 添加插件”中安装 TGZ。 +- 附件名称含构建平台前缀,所有文件的 SHA-256 见 `SHA256SUMS`。 + +## 验证范围 + +发行门禁覆盖源码构建、类型检查、自动化回归和 Windows x64、macOS x64/arm64 打包。自动打包检查不代表各平台全部真实交互与企业后台均已验收;Web 预览也不能代替 Desktop 打包运行时验收。 + +工作台外框适配限定官方 DSH `0.2.1-rc.1` 的两个外框接缝,并校验原始文件与适配后文件的摘要;未知版本或文件内容不匹配时停止处理。官方 Header、对话视图、Composer 与右侧栏的完整组件及状态归属保持不变。该适配不代表所有业务工作台已经完成界面与数据验收。 + +当前为 Alpha 预发布,升级前请备份本地配置和数据。SkillHub、dsh-market 为独立第三方来源,目录内容并非全部预装或经 WorkDSH 审核,安装前请核对许可、依赖与版本兼容性。 + +开源地址:[github.com/techflag/workdsh](https://github.com/techflag/workdsh) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 751c65c727..c5dcc129b8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | set -euo pipefail - node --test scripts/bilingual-docs.test.mjs + node --test scripts/bilingual-docs.test.mjs scripts/ci-scope.test.mjs node scripts/verify-bilingual-docs.mjs if [[ "$EVENT_NAME" == "workflow_dispatch" || "$REF_TYPE" == "tag" ]]; then product=true @@ -61,8 +61,8 @@ jobs: git diff --check "$BASE_SHA" "$HEAD_SHA" product="$(git diff --name-only -z "$BASE_SHA" "$HEAD_SHA" | node scripts/classify-ci-changes.mjs)" changed_paths="$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")" - if [[ -n "$changed_paths" ]] && grep -Eq '^(apps/web/|packages/|profiles/)' <<< "$changed_paths"; then web=true; else web=false; fi - if [[ -n "$changed_paths" ]] && grep -qv '^apps/web/' <<< "$changed_paths"; then desktop=true; else desktop=false; fi + if [[ -n "$changed_paths" ]] && grep -Eq '^(apps/web/|packages/|profiles/)|^scripts/native-workbench-frame-patch\.(mjs|json|d\.mts)$' <<< "$changed_paths"; then web=true; else web=false; fi + if [[ "$product" == "true" && -n "$changed_paths" ]] && grep -qv '^apps/web/' <<< "$changed_paths"; then desktop=true; else desktop=false; fi fi echo "product=$product" >> "$GITHUB_OUTPUT" echo "desktop=$desktop" >> "$GITHUB_OUTPUT" @@ -360,25 +360,14 @@ jobs: shell: bash run: | VERSION="${TAG#desktop-v}" - cat > release-notes.md <<'EOF' - WorkDSH Desktop 将 WorkBuddy 风格的项目、资料、专家、技能和连接器工作台与 DeepSeek Harness 插件能力结合。 - - - 本地插件压缩包按内容校验后保存到应用管理目录,安装后移动或删除原始下载文件不再阻挡后续安装。 - - 新会话创建独立空白会话,不沿用旧草稿的专家、MCP 或资料选择;明确切换专家仍保留当前草稿与引用。 - - 修复 macOS 召唤或切换专家时的 Dock 闪动,后台浏览器从原生启动阶段保持无 Dock 图标,主窗口正常显示。 - - 对话框可直接召唤专家并显示当前专家名称;切换专家保留资料库的可点击引用和 MCP 选择。 - - 资料库支持 CSV/XLSX 导入;专家、资料库和 MCP 的选择浮层保持一致。 - - 内置 MCP 示例随当前安装包定位运行文件,避免升级后引用旧路径。 - - 技能页接入 SkillHub 目录,可查看技能图标、来源、版本与安装入口;本地技能仍可单独管理。 - - 现有插件页可直接发现 DSH 社区插件,并打开第三方 dsh-market 目录进行搜索和安装。 - - Desktop 使用与源码锁一致的官方 DSH Profile;内置 Node.js 和 Python,不额外打包浏览器。 - - 企业连接作为独立 tgz 附件下载,在个人空间“插件 → 添加插件”输入下载文件的完整路径安装,再进入“设置 → 企业账号 → 连接企业”;无需第三方市场。基础安装包不预装企业插件。 - - 提供 Windows x64 安装包,以及 macOS Intel x64、Apple Silicon arm64 未签名 DMG。 - - SkillHub 和 dsh-market 是独立第三方来源;目录内容并非全部预装或经 WorkDSH 审核。安装前请检查许可、依赖和版本兼容性。本次为 Alpha 预发布,升级前请备份本地配置和数据。所有文件的 SHA-256 见 `SHA256SUMS`。 - EOF + NOTES_FILE=".github/release-notes/${TAG}.md" + test -s "$NOTES_FILE" + release_flags=(--latest) + if [[ "$VERSION" == *-* ]]; then + release_flags=(--prerelease --latest=false) + fi gh release create "$TAG" release-assets/* \ --repo "$GITHUB_REPOSITORY" \ --title "WorkDSH Desktop ${VERSION}" \ - --notes-file release-notes.md \ - --latest + --notes-file "$NOTES_FILE" \ + "${release_flags[@]}" diff --git a/AGENTS.md b/AGENTS.md index 4b73028df9..dd7730aa46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,7 +33,7 @@ This repository owns WorkDSH Web and Desktop. Desktop and Web consume the same e ## Current authorized npm migration -- The user explicitly requires the latest published DSH release, currently `0.2.1-rc.1` (2026-10-17). `upstream.json` locks the same npm target and downloaded official Desktop artifacts. Web preview opens the complete native Conversation through public Workspace/Layout APIs, without embedded custom conversation controls; the narrowly authorized local-preview outer-frame patch below applies. Matching artifact hashes do not prove Desktop packaged-runtime acceptance; do not package or release Desktop until its runtime checks pass. +- The user explicitly requires the latest published DSH release, currently `0.2.1-rc.1` (2026-10-17). `upstream.json` locks the same npm target and downloaded official Desktop artifacts. Web preview and Desktop preserve the complete native Conversation, using public Workspace/Layout APIs and only the narrowly authorized shared outer-frame patch below, without embedded custom conversation controls. Matching artifact hashes do not prove Desktop packaged-runtime acceptance; do not release Desktop until its runtime and UI checks pass. ## Personal and enterprise product boundary @@ -46,7 +46,7 @@ This repository owns WorkDSH Web and Desktop. Desktop and Web consume the same e - Never clone, import or compile official DSH source as part of product builds. `upstream.json` locks published package versions and Desktop archive URLs, sizes and SHA-512 hashes. Extract only runtime dependencies, Office assets and prebuilt command helpers; do not carry a second official Electron application or DSH installation. -- Explicit user-authorized exception (2026-10-17): local Web preview may apply the exact-version/checksum-locked `native-workbench-frame-patch` for official `0.2.1-rc.1` to restore the rightmost complete native Conversation. Only the two outer-frame seams may change; preserve original headers, views, Composer and right-sidebar owners. Fail closed on unknown bytes and keep exact rollback. This does not authorize Desktop packaging or release patches. +- Explicit user-authorized exception (Web preview 2026-10-17; Desktop packaging and release 2026-10-18): both may apply the same exact-version/checksum-locked `native-workbench-frame-patch` for official `0.2.1-rc.1` to restore the rightmost complete native Conversation. Only the two outer-frame seams may change; preserve original headers, views, Composer and right-sidebar owners. Fail closed on unknown bytes, verify the packaged result and keep exact rollback. Desktop release still requires actual packaged-runtime and UI acceptance; no other official file changes are authorized. ## Delivery regression gate diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index a8cb22b422..2596e1148e 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -1,3 +1,3 @@ +README.md: 7b9fd895c8a2e0ecb4a77c4cf09ff02b4f9149e5 +README.zh.md: 6c1e914ccc678da3a4ce50046646a40f23cac5a8 # Both languages carry equal authority; these Git blob hashes record the current consistent pair. -README.md: dee36148df4db7857c9a4265c9bfe3c211b153cf -README.zh.md: cea9c21fc39bf43323d9112b5eb0c5db26033cdf diff --git a/apps/desktop/README.md b/apps/desktop/README.md index dee36148df..7b9fd895c8 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -2,13 +2,13 @@ [中文](README.zh.md) -This package builds the Electron application and contains one bundled personal runtime Profile. Official DeepSeek Harness owns the Host, full Web Client and core execution; WorkDSH feature packages supply shared experts, Skills, Library and MCP/connectors. These are the default owned feature boundary, not a promise that every definition or MCP service is preinstalled. The enterprise account plugin is carried in the immutable runtime and selected only after enterprise login; personal defaults do not activate it. Collaboration and notifications remain explicitly installed external plugins. +This package builds the Electron application and contains one bundled personal runtime Profile. Official DeepSeek Harness owns the Host, full Web Client and core execution; shared WorkDSH feature packages supply the product capabilities selected by the reviewed Profile. This does not preinstall every expert definition, Skill or MCP service. Enterprise identity and collaboration are installed explicitly as external plugins and are not included in the base installer. Notifications and independent business applications are also optional external plugins. ## Personal and enterprise connections -The carrier first shows a personal/enterprise chooser. Personal mode starts the personal local Home/Profile. The enterprise administrator fixes the company HTTPS backend origin when packaging; enterprise mode authenticates the verified organization/member in Main; HTTP is allowed only for loopback development. It then runs the same official DSH locally in the member's separate space, rather than opening remote enterprise Web. Enterprise Web continues to run per-account processes on ECS. +The carrier first shows a personal/enterprise chooser. Personal mode starts the personal local Home/Profile. The enterprise administrator fixes the company HTTPS backend origin when packaging; enterprise mode authenticates the verified organization/member in Main; HTTP is allowed only for loopback development. It then runs the same official DSH locally in the member's separate space, rather than opening remote enterprise Web. Enterprise execution remains on the employee's Desktop; the server provides accounts, authorization, collaboration data and model forwarding. -The enterprise account plugin loads from the packaged official dependency graph without an employee tgz installation step. A missing, incompatible or member-local shadowing package prevents enterprise startup. Personal defaults do not select enterprise identity; collaboration and notifications are not automatically installed. Backend, organization and member determine separate configuration, credentials, skill roots and window storage, while both modes use the same official client, settings and right panel. This change supplies account/body adapters without automatically installing collaboration or notifications. +Install and activate the Enterprise Connection archive in the personal space before company login. Missing or incompatible enterprise packages prevent enterprise startup. Backend, organization and member determine separate configuration, credentials, skill roots and window storage; both modes use the same official client, settings and right panel. Installing a plugin does not grant membership or business permissions; the backend verifies them. The backend bearer stays in Main memory; business pages and the DSH CLI never receive it. A controlled loopback interface rechecks the fixed account for each operation and exposes only account, logout and visible-body synchronization. The plugin reuses its account page, showing the organization name, sync status, retry/delete and logout. @@ -23,7 +23,7 @@ The enterprise administrator copies `config/enterprise.example.json`, sets the b ``` ```sh -WORKDSH_DEPLOYMENT_CONFIG=/absolute/path/company.json corepack yarn package:dir +WORKDSH_DEPLOYMENT_CONFIG=/absolute/path/company.json corepack yarn workspace dsh-plugin-desktop package:dir ``` Packaging writes the validated configuration to `workdsh-config.json` in application Resources. Employees see the fixed address and cannot override it. Only `enterprise.backendUrl` is accepted; passwords and Keys are rejected. Personal builds without this environment variable generate empty configuration and cannot inherit a previous company's address. Development uses `corepack yarn dev` with manual setup or `WORKDSH_ENTERPRISE_PORTAL` prefill; this is not the managed deployment configuration. @@ -34,8 +34,9 @@ Use Node.js `^22.19.0` or `>=24` and Corepack Yarn 4.18.0. At the repository roo ```sh corepack yarn install --immutable -corepack pnpm --dir workdsh-web build -node apps/desktop/scripts/pack-workdsh-profile.mjs workdsh-web +corepack pnpm --dir apps/web install --frozen-lockfile +corepack pnpm --dir apps/web build +node apps/desktop/scripts/pack-workdsh-profile.mjs apps/web corepack yarn dev ``` @@ -52,8 +53,8 @@ corepack yarn check:desktop-dsh-alignment `corepack yarn release:pack` builds and packages the same commit's Web sources and the reviewed Desktop composition, preserving the clean-source release gate. The Web release directory also receives `desktop-release-manifest.json` and its hash-named archives for published Desktop packaging. -Build the four owned features, their required support packages and the enterprise account package in WorkDSH first. `pack-workdsh-profile.mjs` creates immutable archives and a version/hash manifest in `build/workdsh-profile-release`; select another candidate with `WORKDSH_RELEASE_DIRECTORY`. Runtime preparation installs the official version with a portable lockfile and carries pnpm's JavaScript CLI for explicit plugin operations. It rejects additional default feature packages and verifies the real plugin manager exposes exactly four WorkDSH bundles. The same Profile preserves the pinned SkillHub 0.2.16 and dshmarket 1.66.8 catalog integrations. Their catalog entries are not all installed or reviewed by WorkDSH. It does not package a live user's configuration or credentials. +Build the shared features and their required support packages first. `pack-workdsh-profile.mjs` creates immutable archives and a version/hash manifest in `build/workdsh-profile-release`; select another candidate with `WORKDSH_RELEASE_DIRECTORY`. Runtime preparation installs the official version with a portable lockfile and carries pnpm's JavaScript CLI for explicit plugin operations. The package inventory in `scripts/workdsh-package-boundary.mjs` defines the reviewed default bundles and required support packages; enterprise plugins are excluded from the base installer. The same Profile preserves the pinned SkillHub 0.2.16 and dshmarket 1.66.8 catalog integrations. Their catalog entries are not all installed or reviewed by WorkDSH. It does not package a live user's configuration or credentials. -The Desktop pin and shared product target are **DSH 0.2.0-rc.2**, selected explicitly by the user. The unmodified official `dsh-v0.2.0-rc.2` tag pins commit `639ed015397290b3745d163aafe02ffee4aa3f84`. Existing installed applications and Profiles have their own actual versions. Desktop alignment, Desktop checks and packaged-runtime acceptance must pass before declaring the unified upgrade complete; Web Docker evidence cannot replace those gates. Prefer validated official stable releases by default. +`upstream.json` locks Desktop and Web to the same official **DSH 0.2.1-rc.1** release, as explicitly required by the user. Published packages and checksum-locked official Desktop archives supply the runtime; product builds do not import or compile official source. Existing applications and Profiles have their own actual versions. Alignment, Desktop checks and packaged-runtime acceptance must pass before declaring an upgrade complete; Web preview evidence cannot replace them. Prefer validated official stable releases by default. -The upstream checkout remains read-only. The carrier source and packaging scripts live here; feature package source belongs to WorkDSH, and the server launcher/gateway recipe is owned by its deploy/member-process directory. See [Desktop ownership](../../docs/desktop-boundaries.md). +The carrier source and packaging scripts live here; shared WorkDSH feature sources live under the repository's `packages/`. Official DSH packages are consumed without a source checkout. Enterprise administration and model forwarding belong to the separate WorkDSH Admin repository. See [Desktop ownership](../../docs/desktop-boundaries.md). diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index cea9c21fc3..6c1e914ccc 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -2,13 +2,13 @@ [English](README.md) -本包构建 Electron 应用,包含一套个人运行时 Profile。官方 DeepSeek Harness 拥有 Host、完整 Web 客户端及核心执行;WorkDSH 功能包提供共同的专家、Skill、资料库和 MCP/连接器。这是默认自有功能边界,不代表预装全部定义或 MCP 服务。企业账号插件随只读运行包提供、仅企业登录后启用;个人 Profile 不启用它。协作与通知仍为显式安装的外置插件。 +本包构建 Electron 应用,包含一套个人运行时 Profile。官方 DeepSeek Harness 拥有 Host、完整 Web 客户端及核心执行;共享 WorkDSH 功能包提供经核对的 Profile 所选产品能力,不代表预装全部专家定义、Skill 或 MCP 服务。企业账号和协作作为外置插件显式安装,基础安装包不预装;通知及独立业务应用同样是可选外置插件。 ## 个人与企业连接 -外壳先显示使用方式选择。个人模式启动个人本机 Home/Profile。企业管理员在打包配置中固定公司后台 HTTPS 地址,员工通过 Main 登录并核验真实组织与成员;HTTP 仅允许回环开发地址。登录后在独立成员空间启动本机官方 DSH,不再打开远端企业 Web。企业 Web 自身仍在 ECS 按账号运行。 +外壳先显示使用方式选择。个人模式启动个人本机 Home/Profile。企业管理员在打包配置中固定公司后台 HTTPS 地址,员工通过 Main 登录并核验真实组织与成员;HTTP 仅允许回环开发地址。登录后在独立成员空间启动本机官方 DSH,不再打开远端企业 Web。企业执行仍在员工桌面端,服务器提供账号、授权、协作数据与模型转发。 -企业账号插件从安装包的共同官方依赖图加载,不需要员工再安装 tgz;缺失、版本不匹配或成员目录同名包遮蔽时阻止企业进入。个人默认组合不选择企业账号;协作与通知不自动安装。企业空间按后台、组织和成员隔离配置、凭据、技能根与窗口存储,仍使用同一官方客户端、设置和右侧面板。本轮只实现账号/正文适配,不自动安装协作或通知。 +先在个人空间安装并启用企业连接插件,再登录公司账号。企业插件缺失或版本不兼容时阻止企业进入。企业空间按后台、组织和成员隔离配置、凭据、技能根与窗口存储;两种模式仍使用同一官方客户端、设置和右侧面板。安装插件不授予成员资格或业务权限,权限由后台核验。 后台登录 bearer 只在 Main 内存,业务页面和 DSH CLI 不持有它。受控本机接口按固定账号逐操作复验,只提供账号、退出和可见正文同步。企业账号页复用插件页面,显示组织名称、同步状态、重试/删除和退出。 @@ -23,7 +23,7 @@ ``` ```sh -WORKDSH_DEPLOYMENT_CONFIG=/absolute/path/company.json corepack yarn package:dir +WORKDSH_DEPLOYMENT_CONFIG=/absolute/path/company.json corepack yarn workspace dsh-plugin-desktop package:dir ``` 打包将校验后的配置写入应用 Resources 的 `workdsh-config.json`,员工入口只显示该地址,不允许改写。配置只接受 `enterprise.backendUrl`,拒绝密码或 Key。未设置此环境变量的个人构建生成空配置,不沿用上一次公司的地址。开发可运行 `corepack yarn dev` 并手填地址,或用 `WORKDSH_ENTERPRISE_PORTAL` 预填;这不是企业交付配置。 @@ -34,8 +34,9 @@ WORKDSH_DEPLOYMENT_CONFIG=/absolute/path/company.json corepack yarn package:dir ```sh corepack yarn install --immutable -corepack pnpm --dir workdsh-web build -node apps/desktop/scripts/pack-workdsh-profile.mjs workdsh-web +corepack pnpm --dir apps/web install --frozen-lockfile +corepack pnpm --dir apps/web build +node apps/desktop/scripts/pack-workdsh-profile.mjs apps/web corepack yarn dev ``` @@ -52,8 +53,8 @@ corepack yarn check:desktop-dsh-alignment `corepack yarn release:pack` 构建并打包同一提交的 Web 源码与经过核对的 Desktop 组合,保留干净源码发行门禁。Web 发行目录同时生成 `desktop-release-manifest.json` 与对应哈希命名制品,供已发布 Desktop 打包使用。 -先在 WorkDSH 构建四个自有功能、必要支撑包及企业账号包。`pack-workdsh-profile.mjs` 在 `build/workdsh-profile-release` 生成不可变制品与版本/哈希清单;其他候选通过 `WORKDSH_RELEASE_DIRECTORY` 显式选择。运行时准备安装固定官方版本,生成可移植锁文件,并携带 pnpm JavaScript CLI 支持显式插件操作。门禁拒绝额外默认功能包,并验证真实插件管理器恰好展示四个 WorkDSH bundle;企业账号仅企业登录后启用。同一 Profile 保留固定的 SkillHub 0.2.16 和 dshmarket 1.66.8 目录集成,不代表目录内容全部安装或经 WorkDSH 审核。不打包正在使用的用户配置或凭据。 +先构建共享功能及其必要支撑包。`pack-workdsh-profile.mjs` 在 `build/workdsh-profile-release` 生成不可变制品与版本/哈希清单;其他候选通过 `WORKDSH_RELEASE_DIRECTORY` 显式选择。运行时准备安装固定官方版本,生成可移植锁文件,并携带 pnpm JavaScript CLI 支持显式插件操作。`scripts/workdsh-package-boundary.mjs` 中的包清单定义经核对的默认 bundle 与必要支撑包,企业插件不包含在基础安装包中。同一 Profile 保留固定的 SkillHub 0.2.16 和 dshmarket 1.66.8 目录集成,不代表目录内容全部安装或经 WorkDSH 审核。不打包正在使用的用户配置或凭据。 -Desktop pin 与共同产品目标均为 **DSH 0.2.0-rc.2**,由用户明确选择。未修改的官方 `dsh-v0.2.0-rc.2` tag 固定提交 `639ed015397290b3745d163aafe02ffee4aa3f84`。既有安装包和 Profile 的实际版本需要单独核验。Desktop alignment、Desktop checks 和 packaged-runtime 验收都通过后才能宣布统一升级完成;Web Docker 证据不能代替。默认选择经过验证的官方稳定版。 +`upstream.json` 按用户明确要求,将 Desktop 与 Web 统一固定为官方 **DSH 0.2.1-rc.1**。运行时来自已发布包与校验和锁定的官方 Desktop 发行文件,产品构建不导入或编译官方源码。既有应用与 Profile 的实际版本需单独核验;版本一致性、Desktop 检查与打包运行时验收全部通过后才能宣布升级完成,Web 预览证据不能代替。默认选择经过验证的官方稳定版。 -上游 checkout 保持只读。外壳源码与打包脚本在本目录,功能包源码属于 WorkDSH,服务器启动器/网关配方由该工程 deploy/member-process 拥有。参见 [Desktop 归属](../../docs/desktop-boundaries.md)。 +外壳源码与打包脚本位于本目录,共享 WorkDSH 功能源码位于仓库 `packages/`。官方 DSH 通过已发布包接入,不检出其源码。企业管理与模型转发由独立的 WorkDSH Admin 仓库负责。参见 [Desktop 归属](../../docs/desktop-boundaries.md)。 diff --git a/apps/desktop/scripts/pack-workdsh-profile.mjs b/apps/desktop/scripts/pack-workdsh-profile.mjs index 9709f8b4b9..ee2113bbc7 100644 --- a/apps/desktop/scripts/pack-workdsh-profile.mjs +++ b/apps/desktop/scripts/pack-workdsh-profile.mjs @@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from ' import { join, relative, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { DSH_VERSION } from './runtime-version.mjs' -import { RELEASE_PACKAGES, PACKAGE_DIRECTORIES } from './workdsh-package-boundary.mjs' +import { PRODUCT_PACKAGES, RELEASE_PACKAGES, PACKAGE_DIRECTORIES } from './workdsh-package-boundary.mjs' import { requiredRuntimeDependencies, verifyBuiltPackageExports, verifyPackageDshReferences, verifyProfileRelease } from './verify-profile-release.mjs' const source = process.argv[2] && resolve(process.argv[2]) const output = resolve(process.argv[3] ?? fileURLToPath(new URL('../build/workdsh-profile-release', import.meta.url))) @@ -53,4 +53,4 @@ const manifest = { kind: 'workdsh-desktop-source-candidate', version: project.ve runtimeOverrides: project.pnpm?.overrides ?? {}, requiredPeers, packages, officialWeb } verifyProfileRelease(manifest, DSH_VERSION, RELEASE_PACKAGES) writeFileSync(join(output, 'release-manifest.json'), JSON.stringify(manifest, null, 2) + '\n') -console.log(`Packed ${packages.length} owned packages for DSH ${DSH_VERSION}; five default features selected, enterprise plugins are distributed separately`) +console.log(`Packed ${packages.length} owned packages for DSH ${DSH_VERSION}; ${PRODUCT_PACKAGES.length} default features selected, enterprise plugins are distributed separately`) diff --git a/apps/desktop/scripts/prepare-workdsh-primary-runtime.mjs b/apps/desktop/scripts/prepare-workdsh-primary-runtime.mjs index 7d2d0bae6e..8fd94a5915 100644 --- a/apps/desktop/scripts/prepare-workdsh-primary-runtime.mjs +++ b/apps/desktop/scripts/prepare-workdsh-primary-runtime.mjs @@ -5,6 +5,7 @@ import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { copyOfficialRuntime, prepareOfficialRelease } from './official-desktop-release.mjs' import { DSH_VERSION, OFFICIAL_PNPM_VERSION } from './runtime-version.mjs' +import { ensureNativeWorkbenchFrame, verifyNativeWorkbenchFrame } from '../../../scripts/native-workbench-frame-patch.mjs' const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..') const target = process.platform === 'win32' ? 'win-x64' @@ -19,6 +20,10 @@ const profileMarker = join(profile, '.workdsh-desktop-release.json') if (!existsSync(profileMarker) || JSON.parse(readFileSync(profileMarker, 'utf8')).harness !== DSH_VERSION) { throw new Error('Prepare and verify the target WorkDSH Profile before its primary runtime') } +// The only authorized official-file adaptation: the exact two outer-frame +// seams. Replace the Profile's directory entry, never a shared pnpm hardlink. +await ensureNativeWorkbenchFrame(profile) +await verifyNativeWorkbenchFrame(profile) const released = await prepareOfficialRelease(desktopRoot, target) for (const name of ['primary-runtime', 'office-skills']) copyOfficialRuntime(join(released, 'runtime', name), join(output, name)) const manifestPath = join(output, 'primary-runtime', 'runtime.json') diff --git a/apps/desktop/scripts/prepare-workdsh-runtime.mjs b/apps/desktop/scripts/prepare-workdsh-runtime.mjs index 584808628b..d37fd1df27 100644 --- a/apps/desktop/scripts/prepare-workdsh-runtime.mjs +++ b/apps/desktop/scripts/prepare-workdsh-runtime.mjs @@ -20,7 +20,7 @@ if (!existsSync(manifestFile)) throw new Error('Pack built current WorkDSH sourc const manifest = JSON.parse(readFileSync(manifestFile, 'utf8')) verifyProfileRelease(manifest, DSH_VERSION, RELEASE_PACKAGES) verifyReleaseArchives(manifest, release) -const marker = JSON.stringify({ harness: DSH_VERSION, pnpm: OFFICIAL_PNPM_VERSION, layout: 'five-feature-plugins-external-enterprise-v6', architecture, release: manifest }) +const marker = JSON.stringify({ harness: DSH_VERSION, pnpm: OFFICIAL_PNPM_VERSION, layout: 'workbench-platform-external-enterprise-v7', architecture, release: manifest }) const markerPath = join(profile, '.workdsh-desktop-release.json') const readJson = path => JSON.parse(readFileSync(path, 'utf8')) function run(command, args, options = {}) { @@ -72,10 +72,10 @@ function verifyInstalled() { for (const id of ['skillhub', 'dsh-market']) { if (!config.stdout.includes('id: ' + id)) throw new Error('Missing community catalog integration: ' + id) } - for (const id of ['workdsh-installation-probe', 'workdsh-identity-local', 'workdsh-access', 'workdsh-audit']) { + for (const id of ['workdsh-installation-probe', 'workdsh-identity-local', 'workdsh-access', 'workdsh-audit', 'workdsh-workbenches']) { if (!config.stdout.includes('id: ' + id)) throw new Error('Missing required infrastructure: ' + id) } - for (const name of ['workdsh-plugin-office', 'workdsh-plugin-activity']) { + for (const name of ['workdsh-plugin-office', 'workdsh-plugin-activity', 'workdsh-plugin-quality-analysis']) { if (existsSync(join(profile, 'node_modules', name)) || config.stdout.includes('name: ' + name)) throw new Error('External feature was bundled by default: ' + name) } run(process.execPath, [join(desktopRoot, 'scripts/verify-product-plugin-inventory.mjs'), output]) @@ -140,4 +140,4 @@ if (!prepared) { } } cpSync(join(profile, 'package.json'), join(output, 'profile-package.json')) -console.log(`Prepared official DSH ${DSH_VERSION} with five default features and no preinstalled enterprise plugins at ${output}; enterprise plugins require explicit installation`) +console.log(`Prepared official DSH ${DSH_VERSION} with ${PRODUCT_PACKAGES.length} default features and no preinstalled enterprise plugins at ${output}; enterprise plugins require explicit installation`) diff --git a/apps/desktop/scripts/verify-product-plugin-inventory.mjs b/apps/desktop/scripts/verify-product-plugin-inventory.mjs index 4792205d7e..c4a4881841 100644 --- a/apps/desktop/scripts/verify-product-plugin-inventory.mjs +++ b/apps/desktop/scripts/verify-product-plugin-inventory.mjs @@ -51,7 +51,7 @@ try { throw new Error(`Community catalog integration is inactive or invalid: ${name}: ${JSON.stringify(bundle)}`) } } - console.log(`Verified plugin manager exposes exactly five ${installedInProfile ? 'installed' : 'installation-provided'} WorkDSH product bundles: ${names.join(', ')}`) + console.log(`Verified plugin manager exposes exactly ${expected.length} ${installedInProfile ? 'installed' : 'installation-provided'} WorkDSH product bundles: ${names.join(', ')}`) } finally { try { await ctx?.fiber.dispose() diff --git a/apps/desktop/scripts/verify-profile-release.test.mjs b/apps/desktop/scripts/verify-profile-release.test.mjs index 555ec30b69..708904a861 100644 --- a/apps/desktop/scripts/verify-profile-release.test.mjs +++ b/apps/desktop/scripts/verify-profile-release.test.mjs @@ -8,7 +8,7 @@ import { tmpdir } from 'node:os' import { dirname, delimiter, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { DSH_VERSION } from './runtime-version.mjs' -import { ENTERPRISE_PACKAGES, PRODUCT_PACKAGES, RELEASE_PACKAGES } from './workdsh-package-boundary.mjs' +import { ENTERPRISE_PACKAGES, PACKAGE_DIRECTORIES, PRODUCT_PACKAGES, RELEASE_PACKAGES } from './workdsh-package-boundary.mjs' test('release bootstrap installs dependencies before loading package validators', () => { const directory = mkdtempSync(join(tmpdir(), 'workdsh clean release ')) @@ -100,18 +100,34 @@ function defaultProfile() { } } -test('Desktop ships five default features, reviewed infrastructure and no preinstalled enterprise package', () => { - assert.equal(PRODUCT_PACKAGES.length, 5) +test('Desktop ships the workbench platform with its default features and keeps business and enterprise plugins external', () => { + assert.deepEqual(PRODUCT_PACKAGES, ['workdsh-plugin-connectors', 'workdsh-plugin-experts', 'workdsh-plugin-library', 'workdsh-plugin-projects', 'workdsh-plugin-skills', 'workdsh-plugin-workbenches']) assert.deepEqual(ENTERPRISE_PACKAGES, ['workdsh-provider-identity-enterprise', 'workdsh-plugin-enterprise-collaboration', 'workdsh-enterprise-connection']) - assert.equal(RELEASE_PACKAGES.length, 12) + assert.equal(RELEASE_PACKAGES.length, 13) assert.doesNotThrow(() => verifyDefaultProfile(defaultProfile())) - for (const name of ['workdsh-plugin-office', 'workdsh-plugin-notifications']) { + for (const name of ['workdsh-plugin-office', 'workdsh-plugin-notifications', 'workdsh-plugin-quality-analysis', ...ENTERPRISE_PACKAGES]) { const profile = defaultProfile() profile.optionalDependencies[name] = 'file:../external.tgz' assert.throws(() => verifyDefaultProfile(profile), /owned dependency closure changed/) } }) +test('Desktop archives close every declared owned runtime dependency and include workbench delivery entry points', () => { + const desktop = fileURLToPath(new URL('..', import.meta.url)) + for (const name of RELEASE_PACKAGES) { + const manifest = JSON.parse(readFileSync(join(desktop, PACKAGE_DIRECTORIES[name], 'package.json'), 'utf8')) + assert.equal(manifest.name, name) + for (const dependency of Object.keys(requiredRuntimeDependencies(manifest)).filter(name => name.startsWith('workdsh-'))) { + assert.ok(RELEASE_PACKAGES.includes(dependency), `${name} requires an unshipped owned package: ${dependency}`) + } + } + const sdk = JSON.parse(readFileSync(join(desktop, PACKAGE_DIRECTORIES['workdsh-plugin-workbenches'], 'package.json'), 'utf8')) + assert.equal(sdk.exports['./delivery'], './scripts/run-delivery.mjs') + assert.equal(sdk.exports['./startup-check'], './scripts/check-startup.mjs') + assert.ok(sdk.files.includes('scripts/run-delivery.mjs')) + assert.ok(sdk.files.includes('scripts/check-startup.mjs')) +}) + test('enterprise delivery cannot activate or expose enterprise identity in the default personal composition', () => { const selected = defaultProfile() selected.dsh.profile.bundles.push(ENTERPRISE_PACKAGES[0]) diff --git a/apps/desktop/scripts/verify-workdsh-carrier.ts b/apps/desktop/scripts/verify-workdsh-carrier.ts index 82b156cad1..86122030a1 100644 --- a/apps/desktop/scripts/verify-workdsh-carrier.ts +++ b/apps/desktop/scripts/verify-workdsh-carrier.ts @@ -8,6 +8,7 @@ import { DSH_VERSION, OFFICIAL_PNPM_VERSION } from './runtime-version.mjs' import { ENTERPRISE_PACKAGES, PRODUCT_PACKAGES, RELEASE_PACKAGES } from './workdsh-package-boundary.mjs' import { verifyDefaultComposition, verifyDefaultProfile, verifyInstalledDshVersions, verifyOfficialWebPackages, verifyProfileRelease, verifyReleaseArchives } from './verify-profile-release.mjs' import { parseDeploymentConfig } from '../src/deployment-config.ts' +import { verifyNativeWorkbenchFrame } from '../../../scripts/native-workbench-frame-patch.mjs' interface PackContext { appOutDir: string @@ -60,6 +61,9 @@ export async function afterPack(context: PackContext): Promise { throw new Error(`Bundled primary runtime is ${String(primary.desktopVersion)}, expected ${DSH_VERSION}`) } const profile = join(runtime, 'profiles', 'workdsh') + // Inspect copied Resources bytes: a preparation log or receipt cannot prove + // that the official Conversation frame actually reached this installer. + await verifyNativeWorkbenchFrame(profile) const cache = join(runtime, 'package-cache') const release = JSON.parse(readFileSync(join(cache, 'release-manifest.json'), 'utf8')) as { packages: Array<{ name: string; version: string; filename: string; sha256: string }> @@ -86,10 +90,10 @@ export async function afterPack(context: PackContext): Promise { const selectedWorkdsh = selected.filter(name => name.startsWith('workdsh-')) const directWorkdsh = Object.keys(profileManifest.dependencies ?? {}).filter(name => name.startsWith('workdsh-')) if (selectedWorkdsh.length !== productPackages.size || selectedWorkdsh.some(name => !productPackages.has(name))) { - throw new Error(`Desktop must select exactly five WorkDSH product bundles, found ${selectedWorkdsh.join(', ')}`) + throw new Error(`Desktop must select exactly ${productPackages.size} WorkDSH product bundles, found ${selectedWorkdsh.join(', ')}`) } if (directWorkdsh.length !== productPackages.size || directWorkdsh.some(name => !productPackages.has(name))) { - throw new Error(`Desktop must directly install exactly five WorkDSH product bundles, found ${directWorkdsh.join(', ')}`) + throw new Error(`Desktop must directly install exactly ${productPackages.size} WorkDSH product bundles, found ${directWorkdsh.join(', ')}`) } const lockfile = readFileSync(join(profile, 'pnpm-lock.yaml'), 'utf8') for (const item of release.packages) { diff --git a/apps/desktop/scripts/workdsh-package-boundary.mjs b/apps/desktop/scripts/workdsh-package-boundary.mjs index e239bcafd4..ae19c7a6d5 100644 --- a/apps/desktop/scripts/workdsh-package-boundary.mjs +++ b/apps/desktop/scripts/workdsh-package-boundary.mjs @@ -6,6 +6,7 @@ export const PRODUCT_PACKAGES = Object.freeze([ 'workdsh-plugin-library', 'workdsh-plugin-projects', 'workdsh-plugin-skills', + 'workdsh-plugin-workbenches', ]) // External enterprise packages: installed explicitly, never included in the base installer. @@ -34,6 +35,7 @@ export const RELEASE_PACKAGES = Object.freeze([ 'workdsh-plugin-library', 'workdsh-bundle', 'workdsh-plugin-projects', + 'workdsh-plugin-workbenches', ]) export const PACKAGE_DIRECTORIES = Object.freeze({ @@ -49,6 +51,7 @@ export const PACKAGE_DIRECTORIES = Object.freeze({ 'workdsh-plugin-library': '../../packages/plugins/library', 'workdsh-bundle': '../../packages/bundle', 'workdsh-plugin-projects': '../../packages/plugins/projects', + 'workdsh-plugin-workbenches': '../../packages/plugins/workbenches', 'workdsh-provider-identity-enterprise': '../../packages/providers/identity-enterprise', 'workdsh-plugin-enterprise-collaboration': '../../packages/plugins/enterprise-collaboration', }) diff --git a/apps/desktop/tests/native-workbench-frame.spec.ts b/apps/desktop/tests/native-workbench-frame.spec.ts new file mode 100644 index 0000000000..36152d592d --- /dev/null +++ b/apps/desktop/tests/native-workbench-frame.spec.ts @@ -0,0 +1,29 @@ +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { ensureNativeWorkbenchFrame, verifyNativeWorkbenchFrame } from '../../../scripts/native-workbench-frame-patch.mjs' + +describe('Desktop native Conversation frame guard', () => { + it('fails closed for unsupported installed bytes and leaves the original file untouched', async () => { + const profile = await mkdtemp(join(tmpdir(), 'workdsh-desktop-frame-')) + const pkg = join(profile, 'node_modules/@deepseek-ai/dsh-client-ui-conversation') + const target = join(pkg, 'lib/client.js') + try { + await mkdir(join(pkg, 'lib'), { recursive: true }) + await writeFile(join(profile, 'package.json'), '{}') + await writeFile(join(pkg, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-client-ui-conversation', version: '0.2.1-rc.1' })) + await writeFile(target, 'unexpected-runtime-bytes') + await expect(ensureNativeWorkbenchFrame(profile)).rejects.toThrow('unsupported runtime bytes') + await expect(verifyNativeWorkbenchFrame(profile)).rejects.toThrow('unsupported packaged runtime bytes') + await expect(ensureNativeWorkbenchFrame(profile, { rollback: true })).rejects.toThrow('Cannot roll back unknown runtime') + expect(await readFile(target, 'utf8')).toBe('unexpected-runtime-bytes') + await writeFile(join(pkg, 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-client-ui-conversation', version: '0.2.1-unknown' })) + await expect(ensureNativeWorkbenchFrame(profile)).rejects.toThrow('runtime version mismatch') + await expect(verifyNativeWorkbenchFrame(profile)).rejects.toThrow('runtime version mismatch') + expect(await readFile(target, 'utf8')).toBe('unexpected-runtime-bytes') + } finally { + await rm(profile, { recursive: true, force: true }) + } + }) +}) diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index df14aace35..45e3f99f6e 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -4,7 +4,7 @@ This independent pnpm workspace owns WorkDSH feature packages and Web compositio ## Runtime and ownership -- Pin official DSH dependencies and required peers to the product target `0.2.1-rc.1`, with Cordis `4.0.5`. Web consumes published official packages. Never require its checkout for a Web build. The explicit 2026-10-17 local-preview exception in root AGENTS.md permits only the checksum-locked rc.1 outer native workbench frame patch; other upstream edits remain prohibited. +- Pin official DSH dependencies and required peers to the product target `0.2.1-rc.1`, with Cordis `4.0.5`. Web consumes published official packages. Never require its checkout for a Web build. The explicit Web-preview and Desktop exception in root AGENTS.md permits only the shared checksum-locked rc.1 two-seam outer native workbench frame patch; other upstream edits remain prohibited. - Personal Web, enterprise Web and Desktop share official runtime targets, owned feature packages, business services and pages. Identity, storage, execution and synchronization vary only at explicit adapter boundaries. - Use official Loader/Profile/Cordis, Session/Agent loop, preset registry, Skill parser/provider, MCP transport, model routing, Remote, Storage and Conversation UI. Do not introduce another Host, Client, loader or executor. - Feature packages own business objects, revisions, authorization and presentation. Page actions and Agent tools use the same business services. Optional cross-plugin collaboration uses public contracts and lifecycle injection, never another plugin's internal implementation. @@ -14,8 +14,8 @@ This independent pnpm workspace owns WorkDSH feature packages and Web compositio Read [requirements](docs/REQUIREMENTS.md), [architecture](docs/ARCHITECTURE.md) and the affected package README. Ownership and adapter changes follow [the feature development contract](docs/FEATURE-DEVELOPMENT-CONTRACT.md). Public interfaces follow [contracts](docs/CONTRACTS.md) and [the official extension standard](docs/HARNESS-OFFICIAL-DEVELOPMENT.md). -- Experts, library, skills and MCP/connectors are the built-in Desktop feature plugins. This does not install every expert, Skill or server definition. -- Enterprise account delivery is an explicit exception: the immutable Desktop runtime ships the account package, activates it only after enterprise login, and rejects member-local copies that shadow it. Personal defaults do not activate it; employees do not install a tgz. +- Experts, library, skills, MCP/connectors, projects and the workbench platform are the built-in Desktop feature plugins. This does not install every expert, Skill, server definition or business application. +- Enterprise identity and collaboration are external plugins installed explicitly through the Enterprise Connection archive. They are excluded from the base installer and inactive in personal mode. Installation does not grant membership; enterprise admission and authorization remain server-verified. - Collaboration, notifications and business applications remain external plugins with independent installation and updates. Other feature delivery follows [plugin delivery](docs/PLUGIN-DELIVERY.md) and [external plugins](docs/EXTERNAL-PLUGINS.md). - A Profile selects configuration/plugins; it is not an account or security boundary. Enterprise member admission, data ownership, credentials, files and actual operations require server-side authorization. - Keep one implementation and one state owner. Enterprise account composition removes personal identity, uses the actual member's credentials, and keeps account storage separate. Never package passwords, internal model keys or server service keys. diff --git a/apps/web/scripts/native-workbench-frame-patch.mjs b/apps/web/scripts/native-workbench-frame-patch.mjs index 109c5d9815..23b13b4230 100644 --- a/apps/web/scripts/native-workbench-frame-patch.mjs +++ b/apps/web/scripts/native-workbench-frame-patch.mjs @@ -1,35 +1,8 @@ -import {readFile,writeFile,rename,rm,stat} from 'node:fs/promises'; -import {createHash,randomUUID} from 'node:crypto'; -import {createRequire} from 'node:module'; -import {dirname,join} from 'node:path'; +// Compatibility entry for local Web preview; Desktop consumes the same locked adapter. import {fileURLToPath} from 'node:url'; -const spec=JSON.parse(await readFile(new URL('./native-workbench-frame-patch.json',import.meta.url),'utf8')); -const hash=value=>createHash('sha256').update(value).digest('hex'); -export function patchNativeWorkbenchFrame(source){ - const digest=hash(source);if(digest===spec.patchedSha256)return source; - if(digest!==spec.originalSha256)throw Error('Native workbench frame: unsupported runtime bytes; refusing to patch.'); - let output=source; - for(const [before,after] of spec.replacements){if(output.split(before).length!==2)throw Error('Native frame patch anchor mismatch');output=output.replace(before,after);} - if(hash(output)!==spec.patchedSha256)throw Error('Native frame patch result mismatch');return output; +import {ensureNativeWorkbenchFrame} from '../../../scripts/native-workbench-frame-patch.mjs'; +export {patchNativeWorkbenchFrame,ensureNativeWorkbenchFrame,verifyNativeWorkbenchFrame} from '../../../scripts/native-workbench-frame-patch.mjs'; +if(process.argv[1]===fileURLToPath(import.meta.url)){ + if(!process.argv[2])throw Error('Profile path required'); + await ensureNativeWorkbenchFrame(process.argv[2],{rollback:process.argv.includes('--rollback')}); } -// Replace this installation's directory entry, never mutate a pnpm hardlink -// in place (which could otherwise change a different Profile or the store). -async function replaceRuntimeFile(target,expected,next){ - const temporary=`${target}.workdsh-frame-${randomUUID()}`; - const mode=(await stat(target)).mode&0o777; - try{ - await writeFile(temporary,next,{flag:'wx',mode}); - if(hash(await readFile(target,'utf8'))!==hash(expected))throw Error('Native frame target changed during update'); - await rename(temporary,target); - }finally{await rm(temporary,{force:true});} -} -export async function ensureNativeWorkbenchFrame(profile,{rollback=false}={}){ - const require=createRequire(join(profile,'package.json')); - const path=require.resolve(`${spec.package}/package.json`),manifest=JSON.parse(await readFile(path,'utf8')); - if(manifest.version!==spec.version)throw Error('Native workbench frame: runtime version mismatch'); - const target=join(dirname(path),spec.file),source=await readFile(target,'utf8'); - if(rollback){if(hash(source)===spec.originalSha256)return;if(hash(source)!==spec.patchedSha256)throw Error('Cannot roll back unknown runtime');let original=source;for(const [before,after] of [...spec.replacements].reverse())original=original.replace(after,before);if(hash(original)!==spec.originalSha256)throw Error('Rollback mismatch');await replaceRuntimeFile(target,source,original);return;} - const patched=patchNativeWorkbenchFrame(source); - if(patched!==source)await replaceRuntimeFile(target,source,patched); -} -if(process.argv[1]===fileURLToPath(import.meta.url)){if(!process.argv[2])throw Error('Profile path required');await ensureNativeWorkbenchFrame(process.argv[2],{rollback:process.argv.includes('--rollback')});} diff --git a/apps/web/scripts/probe-runtime-upgrade.mjs b/apps/web/scripts/probe-runtime-upgrade.mjs index 4e7134b501..aec9dd3c37 100644 --- a/apps/web/scripts/probe-runtime-upgrade.mjs +++ b/apps/web/scripts/probe-runtime-upgrade.mjs @@ -60,7 +60,7 @@ for(const name of ['workdsh-plugin-workbenches','workdsh-plugin-skills','workdsh await step('workbenches-tests',['test:workbenches']); await step('native-layout-api',['exec','node',join(source,'apps/web/scripts/probe-native-conversation-layout.mjs'),join(root,'packages/plugins/workbenches'),join(root,'native-layout-api-report.json')]); try{report.nativeLayout=JSON.parse(await readFile(join(root,'native-layout-api-report.json'),'utf8'));}catch{report.nativeLayout={verified:false};} -const frame=JSON.parse(await readFile(join(source,'apps/web/scripts/native-workbench-frame-patch.json'),'utf8')); +const frame=JSON.parse(await readFile(join(source,'scripts/native-workbench-frame-patch.json'),'utf8')); const require=createRequire(join(root,'packages/plugins/workbenches/package.json')); const actual=JSON.parse(await readFile(require.resolve(frame.package+'/package.json'),'utf8')); report.nativeFrame={adapterVersion:frame.version,runtimeVersion:actual.version,compatible:frame.version===actual.version, diff --git a/apps/web/tests/integration/native-workbench-frame.test.mjs b/apps/web/tests/integration/native-workbench-frame.test.mjs new file mode 100644 index 0000000000..352085a0ac --- /dev/null +++ b/apps/web/tests/integration/native-workbench-frame.test.mjs @@ -0,0 +1,3 @@ +// Include the exact-byte, hardlink, rollback and copied-Resources regression +// checks in the shared-feature CI integration gate. +import '../unit/native-workbench-frame-patch.test.mjs'; diff --git a/apps/web/tests/unit/native-workbench-frame-patch.test.mjs b/apps/web/tests/unit/native-workbench-frame-patch.test.mjs index 6bb1076200..26fc9628fe 100644 --- a/apps/web/tests/unit/native-workbench-frame-patch.test.mjs +++ b/apps/web/tests/unit/native-workbench-frame-patch.test.mjs @@ -1,10 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import {createRequire} from 'node:module'; -import {readFile,writeFile,mkdir,mkdtemp,rm,link} from 'node:fs/promises'; +import {readFile,writeFile,mkdir,mkdtemp,rm,link,cp} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join,dirname} from 'node:path'; -import {patchNativeWorkbenchFrame,ensureNativeWorkbenchFrame} from '../../scripts/native-workbench-frame-patch.mjs'; +import {patchNativeWorkbenchFrame,ensureNativeWorkbenchFrame,verifyNativeWorkbenchFrame} from '../../scripts/native-workbench-frame-patch.mjs'; const require=createRequire(new URL('../../../../packages/plugins/quality-analysis/package.json',import.meta.url)); const manifestPath=require.resolve('@deepseek-ai/dsh-client-ui-conversation/package.json'); const source=await readFile(join(dirname(manifestPath),'lib/client.js'),'utf8'); @@ -22,12 +22,37 @@ test('local copy applies idempotently, rolls back exactly and rejects unknown ve const manifest=JSON.parse(await readFile(manifestPath,'utf8')); await writeFile(join(pkg,'package.json'),JSON.stringify(manifest));await writeFile(join(pkg,'lib/client.js'),source); const untouched=join(profile,'other-profile-original.js');await link(join(pkg,'lib/client.js'),untouched); + await assert.rejects(verifyNativeWorkbenchFrame(profile),/adapter is missing/); await ensureNativeWorkbenchFrame(profile);assert.equal(await readFile(join(pkg,'lib/client.js'),'utf8'),patchNativeWorkbenchFrame(source)); + const verified=await verifyNativeWorkbenchFrame(profile); + assert.equal(verified.version,'0.2.1-rc.1');assert.equal(verified.sha256,'aafebc0109b8d60eb1e1b5e1f3a686c72b86eaf7a3dae8fdd699e1756debc80f'); assert.equal(await readFile(untouched,'utf8'),source,'other hardlinked installations must remain unmodified'); await ensureNativeWorkbenchFrame(profile);await ensureNativeWorkbenchFrame(profile,{rollback:true}); assert.equal(await readFile(join(pkg,'lib/client.js'),'utf8'),source); + await assert.rejects(verifyNativeWorkbenchFrame(profile),/adapter is missing/); await writeFile(join(pkg,'package.json'),JSON.stringify({...manifest,version:'0.0.0-unknown'})); await assert.rejects(ensureNativeWorkbenchFrame(profile),/version mismatch/); assert.equal(await readFile(join(pkg,'lib/client.js'),'utf8'),source); }finally{await rm(profile,{recursive:true,force:true});} }); +test('packaged verification reads copied Resources bytes and refuses missing, changed or mismatched adapters',async()=>{ + const directory=await mkdtemp(join(tmpdir(),'workdsh-packaged-frame-')); + const sourceProfile=join(directory,'build/profile'); + const pkg=join(sourceProfile,'node_modules/@deepseek-ai/dsh-client-ui-conversation'); + const packaged=join(directory,'WorkDSH.app/Contents/Resources/workdsh-runtime/profiles/workdsh'); + try{ + await mkdir(join(pkg,'lib'),{recursive:true});await writeFile(join(sourceProfile,'package.json'),'{}'); + const manifest=JSON.parse(await readFile(manifestPath,'utf8')); + await writeFile(join(pkg,'package.json'),JSON.stringify(manifest));await writeFile(join(pkg,'lib/client.js'),source); + await ensureNativeWorkbenchFrame(sourceProfile);await mkdir(dirname(packaged),{recursive:true});await cp(sourceProfile,packaged,{recursive:true}); + assert.deepEqual(await verifyNativeWorkbenchFrame(packaged),await verifyNativeWorkbenchFrame(sourceProfile)); + const copied=join(packaged,'node_modules/@deepseek-ai/dsh-client-ui-conversation'); + await writeFile(join(copied,'lib/client.js'),source); + await assert.rejects(verifyNativeWorkbenchFrame(packaged),/adapter is missing/); + await writeFile(join(copied,'lib/client.js'),patchNativeWorkbenchFrame(source)+'\n'); + await assert.rejects(verifyNativeWorkbenchFrame(packaged),/unsupported packaged runtime bytes/); + assert.equal((await verifyNativeWorkbenchFrame(sourceProfile)).sha256,'aafebc0109b8d60eb1e1b5e1f3a686c72b86eaf7a3dae8fdd699e1756debc80f','unchanged source receipts cannot hide a changed installer copy'); + await writeFile(join(copied,'package.json'),JSON.stringify({...manifest,version:'0.2.1-unknown'})); + await assert.rejects(verifyNativeWorkbenchFrame(packaged),/version mismatch/); + }finally{await rm(directory,{recursive:true,force:true});} +}); diff --git a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/first-development.md b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/first-development.md index 148e8db60a..5dda737c11 100644 --- a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/first-development.md +++ b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/first-development.md @@ -4,7 +4,7 @@ 2. 从真实开发入口进入官方 Creator(`cordis`),先加载 `cordis-plugin-development`,再加载本 Skill。调用 `workbench_read_development_context` 取得本会话实际关联的已保存需求、源码、插件身份与安装目标,核对原生会话绑定的工作目录,保留已有草稿;权限、发送和工具执行使用官方能力,不能自称已经关联或开始执行。 3. 优先使用开发上下文内联的 SDK 最短示例、版本和现有模板,直接开始本轮业务实现;只核对本轮新增接口,不重复查已确认的注册与构建方式。真实错误或版本变化时再查对应公开契约。使用官方公开模板或已交付 WorkDSH 模板在工作目录开发,不引用机器上的产品源码或绝对 node_modules 路径。保持稳定包名和工作台 ID,静态声明与运行注册一致,清理由插件生命周期管理。 4. 先完成一轮能实际操作的业务流程。AI 协作另外验收:绑定当前对象与完整输入版本 → 原生会话执行真实业务工具 → 候选回到对应对象 → 页面读取展示 → 用户继续加工或采用。切换对象后旧结果不能误写;示例数据明确标注,真实来源未验收不能声称联调完成。仅生成 Agent preset 或配置 bundle,不等于交付了业务 Client UI。 -5. 通过上下文 `sdk.delivery.runner` 或骨架 `npm run deliver` 统一构建、启动检查、打包并记录阶段耗时;不逐个编写检查脚本、不重复手动 build/pack。按 [启动检查](startup-validation.md) 核对产物的真实工具定义与完整插件生命周期,保留具体失败原因;构建成功不能代替启动通过。在用户授权的阶段,依照官方 Skill 与实际模板检查、构建,重新读取开发上下文核对真实候选版本及 tgz 内容摘要。包存在不证明构建来源与当前源码一致。用原生 `plugin_manager` 的 `install_bundle` 安装实际候选,target 为经核实的 tgz 绝对路径、`enabled:false`,读取 `application`、`warnings`。随后以候选的 version、sha256 调用 `workbench_verify_native_installation`,由宿主检查本会话归属、实际安装字节和平台登记。该工具不重新安装、不启用、不重启;回执丢失时先核对已安装结果,不重复执行安装。不具备工具或校验失败时保留实际结果和具体未完成项。 +5. 通过上下文 `sdk.delivery.runner` 或骨架 `node deliver.mjs` 统一构建、启动检查、打包并记录阶段耗时;不逐个编写检查脚本、不重复手动 build/pack。按 [启动检查](startup-validation.md) 核对产物的真实工具定义与完整插件生命周期,保留具体失败原因;构建成功不能代替启动通过。在用户授权的阶段,依照官方 Skill 与实际模板检查、构建,重新读取开发上下文核对真实候选版本及 tgz 内容摘要。包存在不证明构建来源与当前源码一致。用原生 `plugin_manager` 的 `install_bundle` 安装实际候选,target 为经核实的 tgz 绝对路径、`enabled:false`,读取 `application`、`warnings`。随后以候选的 version、sha256 调用 `workbench_verify_native_installation`,由宿主检查本会话归属、实际安装字节和平台登记。该工具不重新安装、不启用、不重启;回执丢失时先核对已安装结果,不重复执行安装。不具备工具或校验失败时保留实际结果和具体未完成项。 6. 平台核对登记后,用户可从工作台列表启用并打开。需要重启才能应用更新时说明原因与当前活动版本,不自行重启无关任务。核对实际活动版本、Host/Client 注册和业务页面,逐项操作本轮流程;让用户从该工作台实际试用。用户要求先看效果或暂停打包时止于该阶段,模拟边界与未完成验收单独说明。通过后保存已验收版本,其余需求保持待办。 ## 开发指令需要携带的上下文 @@ -19,7 +19,7 @@ ## 固定工具链,先检查再安装 -- 新骨架的 `.npmrc` 将 npm 缓存放在项目内,避免用户全局缓存的旧权限影响构建。已有项目出现 `EPERM` 时,先使用 `npm --cache .workdsh-npm-cache install` 或 `npm --cache .workdsh-npm-cache pack`;不要要求 sudo,不修改用户全局缓存权限,也不要用系统 tar 手工拼包。macOS tar 可能混入 AppleDouble 文件及扩展头,原生安装接受不代表平台归档校验通过。 +- 新骨架的 `.npmrc` 将 pnpm store 放在项目内,避免用户全局缓存的旧权限影响构建。通过官方工作区依赖工具取得当前 Node 与 pnpm JavaScript 入口,用 ` install` 安装开发依赖,交付直接运行 `node deliver.mjs`;不要要求 sudo,不修改用户全局缓存权限,也不要用系统 tar 手工拼包。macOS tar 可能混入 AppleDouble 文件及扩展头,原生安装接受不代表平台归档校验通过。 - 客户端导入 `workdsh-plugin-workbenches/client` 时保持为 external,由宿主提供同一份 SDK,不能重复打包平台和 React。骨架 build.mjs 已包含这项设置。 - 安装前再调用 `workbench_read_development_context`,必须在 `candidates` 找到同版本、同 SHA256 且 `compatible:true` 的候选。候选的 `errorCode` 要先修复,不能跳过包检查直接安装。哈希来自同一份最终 tgz,重打包后重新读取,不使用旧哈希。 - 安装等待授权是独立状态。清楚告诉用户“等待安装授权”,不将此时间描述为代码生成或持续思考。 diff --git a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/startup-validation.md b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/startup-validation.md index d43bbaefbb..e21ace2dca 100644 --- a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/startup-validation.md +++ b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/startup-validation.md @@ -17,7 +17,7 @@ content: { type: 'string', required: true } ## 优先运行平台共用检查 -新工程优先执行 `npm run deliver`,平台统一构建、启动检查和打包,输出每个阶段的起止时间、耗时及失败原因;任一步失败即停止。上下文提供 `sdk.delivery.runner` 时,旧工程也可直接运行该平台入口,不必修改业务包。报告中的 running 阶段没有完成回执时视为中断、结果未确认,不当成通过。该报告是诊断信息,不能代替平台独立核验。兼容旧工程的 `check:startup` 与 `npm pack` prepack 仍保留。已有工程通过开发上下文 SDK 目录执行 `node /scripts/check-startup.mjs `;必须在官方开发工具当前授权和沙箱中运行。不要为每个应用重新摸索 Cordis 生命周期或复制平台内部检查实现。 +新工程优先执行 `node deliver.mjs`,平台统一构建、启动检查和打包,输出每个阶段的起止时间、耗时及失败原因;任一步失败即停止。上下文提供 `sdk.delivery.runner` 时,旧工程也可直接运行该平台入口,不必修改业务包。报告中的 running 阶段没有完成回执时视为中断、结果未确认,不当成通过。该报告是诊断信息,不能代替平台独立核验。兼容旧工程的 `check:startup` 与打包 prepack 仍保留;新骨架 prepack 直接调用 Node,不要求 npm。已有工程通过开发上下文 SDK 目录执行 `node /scripts/check-startup.mjs `;必须在官方开发工具当前授权和沙箱中运行。不要为每个应用重新摸索 Cordis 生命周期或复制平台内部检查实现。 回执失败时按 `stage` 和 `errorCode` 修复。缺少服务时,用应用自己的 `workdsh.startupFixture` 测试夹具提供公开实际依赖、临时 Storage 与合成身份;默认只有显式标记的注册测试适配器。不得为得到通过而忽略挂起的子组件。子进程和临时 Home 不是安全沙箱,仍需官方工具约束。通过只代表当前 Host 产物的隔离生命周期,不代表安装、实际注册、页面或业务验收通过。 diff --git a/packages/plugins/workbenches/README.md b/packages/plugins/workbenches/README.md index 5730f019e9..6159f18766 100644 --- a/packages/plugins/workbenches/README.md +++ b/packages/plugins/workbenches/README.md @@ -45,7 +45,7 @@ TypeScript 页面参数从公开 Client 入口导入 `WorkbenchPageProps`;其 类型声明中的 `from './something.js'` 是 TypeScript 的标准 ESM 引用,同目录的 `something.d.ts` 提供类型;它不保证有同名运行时 JS。此包 Host/Client 实现已合并打包,不读取 `dist/client/*.js`、反编译 `client.browser.js` 或枚举平台私有类来推断用法。确需新能力时只核对该项官方公开契约。普通本地页面不要为未要求的业务引入存储域、跨进程通信或 Agent 工具;企业或跨账号数据另行采用已授权 Host 服务。 -构建沿用骨架 `build.mjs`,Client SDK 保持 external。先 `npm install`、`npm run build`、`npm pack`,再读取开发上下文核对候选。完整启动和真实页面验收仍须完成。 +构建沿用骨架 `build.mjs`,Client SDK 保持 external。先用官方工作区依赖工具提供的 Node 与 pnpm JavaScript 入口安装开发工具,再执行 `node deliver.mjs`,读取开发上下文核对候选。完整启动和真实页面验收仍须完成。 ## 已接入的产品代码 @@ -103,11 +103,11 @@ Host 从包根 `import type {HostWorkbenchRegistration, WorkbenchDescriptor} fro 这是开发源码恢复点,不是业务数据备份。目前尚无用户恢复入口、更新前自动恢复协议或源树整体写锁,不能据此宣布迁移/回滚完成。 -## 完整原生会话与预览外框 +## 完整原生会话与工作台外框 -本机 preview 根据用户 2026-10-17 的明确授权,使用精确锁定官方 `0.2.1-rc.1` 文件版本及 SHA256 的两处外框补丁。补丁只把官方已构造的完整 Conversation 节点交给 WorkDSH 外框;页头、对话/轨迹、消息、工具、输入框、模型、权限与文件侧栏仍由官方实现。业务区居中,完整原生会话在最右侧,不添加自制会话标签栏。 +本机 preview 根据用户 2026-10-17 的明确授权,使用精确锁定官方 `0.2.1-rc.1` 文件版本及 SHA256 的两处外框补丁;用户于 2026-10-18 明确允许同一补丁用于 Desktop,验证后发布。补丁只把官方已构造的完整 Conversation 节点交给 WorkDSH 外框;页头、对话/轨迹、消息、工具、输入框、模型、权限与文件侧栏仍由官方实现。业务区居中,完整原生会话在最右侧,不添加自制会话标签栏。 -这是修改官方发布文件的本机预览适配,不是未修改宿主的公共接口。未知版本或字节拒绝应用,支持精确回滚;本轮授权不扩展到 Desktop 发布制品。启动前由 `native-workbench-frame-patch` 核对,不能把底座升级仅视为版本号变更。 +这是修改官方发布文件的适配,不是未修改宿主的公共接口。Web 与 Desktop 共用 `scripts/native-workbench-frame-patch` 的同一版本和输入、输出 SHA256;未知版本或字节拒绝应用,原子替换仅改变本 Profile 的文件项,不修改其他安装的 pnpm 硬链接,并支持精确回滚。Desktop 准备阶段应用补丁,`afterPack` 再读取安装包 Resources 中的实际文件并核对输出指纹;构建检查通过仍需真实安装包的启动与页面验收,不能把底座升级仅视为版本号变更。 外框只管理自有业务页面和自有容器。原生 Conversation 节点保持一个固定 Portal 位置,收起与展开不创建第二份聊天或搬动官方 DOM。需求、安装进度与原生会话同屏;打开记录不自动发送,明确开始开发才调用既有原生提交事务。 @@ -137,12 +137,12 @@ WorkDSH 注册服务是本平台扩展,不属于官方预生成的 Service 接 ## 新建业务包的固定构建边界 -平台生成的骨架使用项目内 `.workdsh-npm-cache`,避免继承全局 npm 缓存的旧权限。构建时将 React、React DOM 与 `workdsh-plugin-workbenches/client` 留给宿主提供,不把平台重复打入业务包。使用 `npm pack` 产生候选;npm 缓存失败时指定项目缓存,不能用 macOS 系统 tar 临时拼包。安装前读取开发上下文,确认同一最终 tgz 的版本、SHA256 与兼容检查通过。检查失败保留具体错误,不能把被拒归档笼统称为候选不存在。 +平台生成的骨架使用项目内 `.workdsh-pnpm-store`,避免继承全局缓存的旧权限;源码恢复点同时排除该目录与旧版 `.workdsh-npm-cache`。构建时将 React、React DOM 与 `workdsh-plugin-workbenches/client` 留给宿主提供,不把平台重复打入业务包。通过统一交付入口执行构建、启动检查与 pnpm 打包,不能用 macOS 系统 tar 临时拼包。安装前读取开发上下文,确认同一最终 tgz 的版本、SHA256 与兼容检查通过。检查失败保留具体错误,不能把被拒归档笼统称为候选不存在。 ### 共用安装前启动检查 -公开入口 `workdsh-plugin-workbenches/startup-check` 提供 `checkWorkbenchStartup(sourceDirectory)`。仅在用户已授权的官方开发工具沙箱中执行;不要在 Host 包预览 RPC 中 import 候选代码。新工程的 `npm pack` 默认先 build,再运行 `check:startup`,失败会退出并阻止打包。已有工程也可执行 `node /scripts/check-startup.mjs <工程目录>`,不用由 Agent 每次重写 Cordis 启动脚本。 +公开入口 `workdsh-plugin-workbenches/startup-check` 提供 `checkWorkbenchStartup(sourceDirectory)`。仅在用户已授权的官方开发工具沙箱中执行;不要在 Host 包预览 RPC 中 import 候选代码。新工程优先执行 `node deliver.mjs`,先 build,再检查启动,失败即阻止打包;独立执行 prepack 时同样保留检查。已有工程也可执行 `node /scripts/check-startup.mjs <工程目录>`,不用由 Agent 每次重写 Cordis 启动脚本。 检查在子进程中加载真实 Host 构建产物、解析声明运行依赖,使用官方 `ctx.plugin`,等待全部当前子组件生命周期,并检查依赖挂起和清理错误。15 秒超时会停止检查。子进程清空模型密钥等继承环境,使用临时 Home;临时目录与子进程不等于 OS 沙箱,执行权限仍由官方工具沙箱控制。 @@ -155,7 +155,7 @@ WorkDSH 注册服务是本平台扩展,不属于官方预生成的 Service 接 ### 共用交付入口与耗时 -新骨架执行 `npm run deliver`;已有工程在官方开发工具的当前权限、沙箱内执行 `node `。公开入口 `workdsh-plugin-workbenches/delivery` 统一执行 build → 隔离 Host 启动检查 → pack,失败立即停止;打包关闭重复生命周期脚本,避免再次构建。候选不覆盖同名归档。报告保存于独立临时目录,逐阶段写入真实起止时间和耗时,不把日志、凭据或报告混入业务源码与归档。未完成的 running 记录视为中断、结果未确认。 +新骨架执行 `node deliver.mjs`;已有工程在官方开发工具的当前权限、沙箱内执行 ` `。交付入口用 Node 运行已安装或内置的 pnpm JavaScript 文件,不依赖系统 npm、全局 pnpm 或 Windows .cmd 启动器;内置版本不匹配时明确停止。公开入口 `workdsh-plugin-workbenches/delivery` 统一执行 build → 隔离 Host 启动检查 → pack,失败立即停止;打包关闭重复生命周期脚本,避免再次构建。候选不覆盖同名归档。报告保存于独立临时目录,逐阶段写入真实起止时间和耗时,不把日志、凭据或报告混入业务源码与归档。未完成的 running 记录视为中断、结果未确认。 开发上下文内联当前已安装版本 README 的最短接入示例、文档指纹及公共交付入口。先用示例与骨架,需要额外能力或出现实际接口错误时再查对应公开类型。这不执行 SDK,也不枚举私有实现。报告只是开发诊断;该入口的阶段报告覆盖构建、启动检查、打包;安装后状态与真实页面验收仍独立核对。 diff --git a/packages/plugins/workbenches/scripts/run-delivery.mjs b/packages/plugins/workbenches/scripts/run-delivery.mjs index 0d0d1b4958..14e7d2cd62 100644 --- a/packages/plugins/workbenches/scripts/run-delivery.mjs +++ b/packages/plugins/workbenches/scripts/run-delivery.mjs @@ -1,34 +1,80 @@ import {spawn} from 'node:child_process'; -import {mkdtemp,readFile,writeFile,rename,rm,realpath} from 'node:fs/promises'; +import {mkdtemp,readFile,writeFile,rename,rm,realpath,readdir} from 'node:fs/promises'; import {tmpdir} from 'node:os'; -import {join,resolve,basename,dirname} from 'node:path'; +import {join,resolve,basename,dirname,delimiter,relative,isAbsolute} from 'node:path'; import {fileURLToPath} from 'node:url'; +import {createRequire} from 'node:module'; import {createHash} from 'node:crypto'; import {performance} from 'node:perf_hooks'; import {checkWorkbenchStartup} from './check-startup.mjs'; +/** Resolve installed pnpm JavaScript, never a system npm or a .cmd wrapper. */ +export async function resolveWorkbenchPackageManager(directory,{environment=process.env,nodeExecutable=process.execPath}={}){ + const inspect=async(entry,expectedVersion)=>{ + const root=await realpath(dirname(dirname(entry))); + const pkg=JSON.parse(await readFile(join(root,'package.json'),'utf8')); + if(pkg.name!=='pnpm'||!/^\d+\.\d+\.\d+$/.test(pkg.version)||!/^bin\/pnpm\.(?:cjs|mjs)$/.test(pkg.bin?.pnpm??'')||(expectedVersion&&pkg.version!==expectedVersion))throw Error('invalid-pnpm'); + const path=await realpath(join(root,pkg.bin.pnpm)); + const child=relative(root,path);if(isAbsolute(child)||child.startsWith('..'))throw Error('invalid-pnpm-entry'); + return {entry:path,name:'pnpm',version:pkg.version}; + }; + const primary=environment.DSH_BUNDLED_PRIMARY_RUNTIME; + if(primary){ + try{const metadata=JSON.parse(await readFile(join(primary,'runtime.json'),'utf8'));if(typeof metadata.pnpm!=='string')throw Error();return {...await inspect(join(primary,'dependencies/pnpm/bin/pnpm.cjs'),metadata.pnpm),source:'bundled-primary-runtime'};} + catch{throw Error('workbench/delivery-package-manager-invalid');} + } + const candidates=[]; + for(const anchor of [join(resolve(directory),'package.json'),fileURLToPath(import.meta.url)]){ + for(const modules of createRequire(anchor).resolve.paths('pnpm')??[])candidates.push(join(modules,'pnpm/bin/pnpm.cjs')); + } + // Official development tools can expose the bundled Node without inheriting + // DSH environment variables. Its sibling pnpm has the same public layout. + candidates.push(join(dirname(nodeExecutable),'../../pnpm/bin/pnpm.cjs')); + for(const part of (environment.PATH??'').split(delimiter).filter(Boolean))candidates.push(join(part,'pnpm.cjs'),join(part,'../pnpm/bin/pnpm.cjs')); + for(const candidate of candidates){try{return {...await inspect(candidate),source:'installed-pnpm'};}catch{}} + throw Error('workbench/delivery-package-manager-unavailable'); +} + // Run ONLY inside the official development tool's authorization/sandbox. // This runner is not a sandbox or an installation/acceptance receipt. -export async function runWorkbenchDelivery(directory,{signal,commandTimeoutMs=120000,startupTimeoutMs=15000}={}){ +export async function runWorkbenchDelivery(directory,{signal,commandTimeoutMs=120000,startupTimeoutMs=15000,environment=process.env}={}){ const source=await realpath(directory),work=await mkdtemp(join(tmpdir(),'workdsh-delivery-')); const reportPath=join(work,'report.json'),packDirectory=await mkdtemp(join(tmpdir(),'workdsh-pack-')); const report={schemaVersion:1,status:'running',startedAt:new Date().toISOString(),scope:'development-build-startup-pack',stages:[],installation:'not-checked',clientPage:'not-checked',businessAcceptance:'not-checked'}; const total=performance.now(); + let packageManager; const save=async()=>{await writeFile(reportPath+'.tmp',JSON.stringify(report,null,2),{mode:0o600});await rename(reportPath+'.tmp',reportPath);}; const command=(args)=>new Promise((accept,reject)=>{ signal?.throwIfAborted(); // Do not inherit model/provider credentials. No automatic privilege escalation. - const env={PATH:dirname(process.execPath)+(process.platform==='win32'?';':':')+(process.env.PATH??''),HOME:work,USERPROFILE:work,TMPDIR:work,TEMP:work,TMP:work,npm_config_cache:join(work,'npm-cache'),npm_config_update_notifier:'false'}; - for(const name of ['SystemRoot','ComSpec'])if(process.platform==='win32'&&process.env[name])env[name]=process.env[name]; - const child=spawn(process.platform==='win32'?'npm.cmd':'npm',args,{cwd:source,env,detached:process.platform!=='win32',stdio:['ignore','pipe','ignore']}); - let output='',overflow=false,timedOut=false; - const stop=()=>{try{if(process.platform!=='win32'&&child.pid)process.kill(-child.pid,'SIGKILL');else child.kill('SIGKILL');}catch{}}; + const env={PATH:dirname(process.execPath)+delimiter+(environment.PATH??''),HOME:work,USERPROFILE:work,TMPDIR:work,TEMP:work,TMP:work,npm_config_store_dir:join(work,'pnpm-store'),npm_config_update_notifier:'false'}; + for(const name of ['SystemRoot','ComSpec','PATHEXT','WINDIR'])if(process.platform==='win32'&&environment[name])env[name]=environment[name]; + // Package scripts still use the OS script shell; do not rely on finding it + // in a PATH that may intentionally contain only the bundled Node directory. + const scriptShell=process.platform==='win32'?(environment.ComSpec??(environment.SystemRoot?join(environment.SystemRoot,'System32/cmd.exe'):undefined)):'/bin/sh'; + const fixedManager=Number(packageManager.version.split('.')[0])>=11?['--pm-on-fail=ignore','--config.verify-deps-before-run=false']:['--config.manage-package-manager-versions=false','--config.package-manager-strict=false']; + const child=spawn(process.execPath,[packageManager.entry,...fixedManager,...(scriptShell?['--config.script-shell='+scriptShell]:[]),...args],{cwd:source,env,detached:process.platform!=='win32',stdio:['ignore','pipe','ignore']}); + let output='',overflow=false,timedOut=false,settled=false,finalizeTimer; + const stop=()=>{ + try{ + if(process.platform!=='win32'&&child.pid)process.kill(-child.pid,'SIGKILL'); + else if(child.pid&&environment.SystemRoot){ + const killer=spawn(join(environment.SystemRoot,'System32/taskkill.exe'),['/PID',String(child.pid),'/T','/F'],{stdio:'ignore',windowsHide:true}); + killer.once('error',()=>{try{child.kill('SIGKILL');}catch{}}); + killer.unref(); + }else child.kill('SIGKILL'); + }catch{} + // Detached grandchildren can keep an inherited pipe open after pnpm exits. + // A timeout/cancellation report must not wait forever for their stdio. + child.stdout.destroy();child.unref();finalizeTimer=setTimeout(()=>finish(null),1000); + }; const abort=()=>stop();signal?.addEventListener('abort',abort,{once:true});if(signal?.aborted)stop(); - const clear=()=>{clearTimeout(timer);signal?.removeEventListener('abort',abort);}; + const clear=()=>{clearTimeout(timer);clearTimeout(finalizeTimer);signal?.removeEventListener('abort',abort);}; + const finish=code=>{if(settled)return;settled=true;clear();if(signal?.aborted)return reject(Error('workbench/delivery-aborted'));if(timedOut)return reject(Error('workbench/delivery-command-timeout'));if(overflow)return reject(Error('workbench/delivery-output-limit'));if(code!==0)return reject(Error('workbench/delivery-command-failed'));accept(output);}; const timer=setTimeout(()=>{timedOut=true;stop();},commandTimeoutMs); child.stdout.on('data',chunk=>{if(Buffer.byteLength(output)+chunk.length>65536){overflow=true;stop();}else output+=chunk.toString('utf8');}); - child.on('error',error=>{clear();reject(error);}); - child.on('close',code=>{clear();if(signal?.aborted)return reject(Error('workbench/delivery-aborted'));if(timedOut)return reject(Error('workbench/delivery-command-timeout'));if(overflow)return reject(Error('workbench/delivery-output-limit'));if(code!==0)return reject(Error('workbench/delivery-command-failed'));accept(output);}); + child.on('error',error=>{if(settled)return;settled=true;clear();reject(error);}); + child.on('close',finish); }); const stage=async(name,run)=>{ signal?.throwIfAborted();const item={name,status:'running',startedAt:new Date().toISOString()},start=performance.now();report.stages.push(item);await save(); @@ -38,12 +84,12 @@ export async function runWorkbenchDelivery(directory,{signal,commandTimeoutMs=12 }; try{ await save(); - await stage('build',()=>command(['run','build'])); + await stage('build',async()=>{packageManager=await resolveWorkbenchPackageManager(source,{environment});report.packageManager={name:packageManager.name,version:packageManager.version,source:packageManager.source};await command(['run','build']);}); await stage('startup-check',async()=>{report.startup=await checkWorkbenchStartup(source,{timeoutMs:startupTimeoutMs,signal});if(report.startup.status!=='passed')throw Error(report.startup.errorCode??'workbench/delivery-startup-failed');}); await stage('pack',async()=>{ // Startup already ran: suppress package lifecycle scripts to avoid duplicate builds. - const packed=JSON.parse(await command(['pack','--ignore-scripts','--offline','--json','--pack-destination',packDirectory])); - const filename=packed?.[0]?.filename;if(packed.length!==1||typeof filename!=='string'||basename(filename)!==filename||!filename.endsWith('.tgz'))throw Error('workbench/delivery-invalid-pack-result'); + await command(['--config.ignore-scripts=true','--config.offline=true','pack','--pack-destination',packDirectory]); + const packed=await readdir(packDirectory),filename=packed[0];if(packed.length!==1||typeof filename!=='string'||basename(filename)!==filename||!filename.endsWith('.tgz'))throw Error('workbench/delivery-invalid-pack-result'); const bytes=await readFile(join(packDirectory,filename)); // Never silently replace an immutable version with different bytes. const destination=join(source,filename);try{await writeFile(destination,bytes,{flag:'wx',mode:0o600});}catch(error){if(error?.code==='EEXIST')throw Error('workbench/delivery-archive-already-exists');throw error;} diff --git a/packages/plugins/workbenches/src/runtime/development-starter.ts b/packages/plugins/workbenches/src/runtime/development-starter.ts index bdb4bd7d8a..c0525095db 100644 --- a/packages/plugins/workbenches/src/runtime/development-starter.ts +++ b/packages/plugins/workbenches/src/runtime/development-starter.ts @@ -5,7 +5,7 @@ export function createWorkbenchStarter(packageName:string,descriptor:WorkbenchDe const modules=JSON.stringify(system?.modules.map(module=>[module.id,module.title])??[['overview','总览'],['data','数据'],['settings','设置']]); const manifest={name:packageName,version:'0.1.0',private:true,type:'module', exports:{'.':{types:'./dist/index.d.ts',default:'./dist/index.js'},'./client':{types:'./dist/client.d.ts',default:'./dist/client.browser.js'},'./package.json':'./package.json'}, - files:['dist','cordis.patch.yml','README.md'],scripts:{build:'node build.mjs','check:startup':'node check-startup.mjs',deliver:'node deliver.mjs',prepack:'npm run build && npm run check:startup'}, + files:['dist','cordis.patch.yml','README.md'],scripts:{build:'node build.mjs','check:startup':'node check-startup.mjs',deliver:'node deliver.mjs',prepack:'node build.mjs && node check-startup.mjs'}, dsh:{bundle:{patch:'./cordis.patch.yml'},client:{platform:'web',inject:['workdsh-plugin-workbenches']}}, workdsh:{workbench:descriptor}, peerDependencies:{'@deepseek-ai/cordis':'4.0.5','workdsh-plugin-workbenches':'^'+sdkPackageVersion}, @@ -13,14 +13,14 @@ export function createWorkbenchStarter(packageName:string,descriptor:WorkbenchDe return {manifest,files:{ 'deliver.mjs':"import {runWorkbenchDelivery} from 'workdsh-plugin-workbenches/delivery';\nconst result=await runWorkbenchDelivery(process.cwd());console.log(JSON.stringify(result,null,2));if(result.status!=='passed')process.exitCode=1;\n", 'check-startup.mjs':"import {checkWorkbenchStartup} from 'workdsh-plugin-workbenches/startup-check';\nconst result=await checkWorkbenchStartup(process.cwd());console.log(JSON.stringify(result,null,2));if(result.status!=='passed')process.exitCode=1;\n", - '.npmrc':'# Host runtime peers are verified on installation, not downloaded for this JS build.\nlegacy-peer-deps=true\ncache=.workdsh-npm-cache\n', - '.gitignore':'node_modules/\ndist/\n.workdsh-npm-cache/\n*.tgz\n', + '.npmrc':'# Host runtime peers are verified on installation, not downloaded for this JS build.\nauto-install-peers=false\nstrict-peer-dependencies=false\nstore-dir=.workdsh-pnpm-store\n', + '.gitignore':'node_modules/\ndist/\n.workdsh-pnpm-store/\n*.tgz\n', 'index.d.ts':"import type {Context} from '@deepseek-ai/cordis';\nimport type {WorkbenchDescriptor,WorkbenchSystemDefinition} from 'workdsh-plugin-workbenches';\nexport declare const inject: string[];\nexport declare const descriptor: WorkbenchDescriptor;\nexport declare function apply(ctx:Context):Promise;\n", 'client.d.ts':"import type {Context} from '@deepseek-ai/cordis';\nexport declare const inject: string[];\nexport declare function apply(ctx:Context):Promise;\n", 'index.js':`import manifest from './package.json';\nexport const inject=['workdshWorkbenchRegistration'];\nexport const descriptor=manifest.workdsh.workbench;\nexport async function apply(ctx){await ctx.get('workdshWorkbenchRegistration').registerManaged(ctx,descriptor,${JSON.stringify(packageName+'-page')});}\n`, 'client.jsx':`import React,{useState} from 'react';\nimport manifest from './package.json';\nexport const inject=['workdshWorkbenchClientRegistration'];\nconst descriptor=manifest.workdsh.workbench;\nconst modules=${modules};\nfunction Page(){const [module,setModule]=useState(modules[0][0]);return

{descriptor.title}

{modules.find(([id])=>id===module)?.[1]}

插件骨架已加载。本模块业务功能尚未实现,请根据确认的需求继续开发。

;}\nexport async function apply(ctx){await ctx.get('workdshWorkbenchClientRegistration').registerManaged(ctx,descriptor,Page);}\n`, 'build.mjs':`import {build} from 'esbuild';\nimport {mkdir,writeFile,readFile,copyFile} from 'node:fs/promises';\nawait mkdir('dist',{recursive:true});\nawait copyFile('index.d.ts','dist/index.d.ts');\nawait copyFile('client.d.ts','dist/client.d.ts');\nawait build({entryPoints:['index.js'],outfile:'dist/index.js',bundle:true,platform:'node',format:'esm'});\nconst manifest=JSON.parse(await readFile('package.json','utf8'));\nconst client=await build({entryPoints:['client.jsx'],bundle:true,write:false,platform:'browser',format:'cjs',target:'es2022',external:['react','react/jsx-runtime','react-dom','workdsh-plugin-workbenches/client']});\nawait writeFile('dist/client.browser.js','window.__ModuleLoader__.load({id:'+JSON.stringify(manifest.name)+',factory:function(require){const module={exports:{}};\\n'+client.outputFiles[0].text+'\\nreturn module.exports;}});\\n');\n`, 'cordis.patch.yml':`- insert:\n - id: ${JSON.stringify(packageName)}\n name: ${JSON.stringify(packageName)}\n`, - 'README.md':'# 工作台插件开发骨架\n\nHost 与 Client 从 package.json 的 workdsh.workbench 读取同一份注册声明;修改标题、说明或能力时修改该声明,重新构建,不另写一份声明。\n\n这是可构建的多模块入口,不是已完成的业务插件。先核对需求和实际安装平台版本,再实现数据、权限、持久化与模块内容。工作台应按可长期使用的软件组织:根据业务提供记录列表、详情与返回路径,通过应用内导航或页签切换任务,避免全部功能纵向堆叠;工具栏保持可达,内容有界滚动,列表随数量增长提供搜索和分页。多记录或一次性工具按已确认需求决定,不把硬编码演示对象当成整个软件。\n\n在此目录执行 npm install 安装开发工具后执行 npm run deliver,平台统一构建、启动检查和打包并记录各阶段耗时;不要逐个应用编写交付脚本。项目 .npmrc 只跳过构建时自动下载宿主 peer;不会移除 package.json 的运行依赖声明,安装时仍由平台核对版本与来源。若要使用 TypeScript 或新增公开 SDK 导入,需另外取得实际平台 SDK 制品及所需类型依赖,不能把这个 JS 骨架当作完整 SDK 类型验收。先由平台检查 tgz 并试用,更新沿用包名和 workbenchId;不要自动替换已有插件或数据。不要复制官方内核代码,不使用 Puck。复用系统 Office 与公开领域服务。\n', + 'README.md':'# 工作台插件开发骨架\n\nHost 与 Client 从 package.json 的 workdsh.workbench 读取同一份注册声明;修改标题、说明或能力时修改该声明,重新构建,不另写一份声明。\n\n这是可构建的多模块入口,不是已完成的业务插件。先核对需求和实际安装平台版本,再实现数据、权限、持久化与模块内容。工作台应按可长期使用的软件组织:根据业务提供记录列表、详情与返回路径,通过应用内导航或页签切换任务,避免全部功能纵向堆叠;工具栏保持可达,内容有界滚动,列表随数量增长提供搜索和分页。多记录或一次性工具按已确认需求决定,不把硬编码演示对象当成整个软件。\n\n先通过官方工作区依赖工具取得当前 Node 与 pnpm JavaScript 入口,以 ` install` 安装开发工具,再执行 `node deliver.mjs`,平台统一构建、启动检查和打包并记录各阶段耗时;不要逐个应用编写交付脚本。项目 .npmrc 将 pnpm store 放在项目内,并跳过构建时自动下载宿主 peer;不会移除 package.json 的运行依赖声明,安装时仍由平台核对版本与来源。若要使用 TypeScript 或新增公开 SDK 导入,需另外取得实际平台 SDK 制品及所需类型依赖,不能把这个 JS 骨架当作完整 SDK 类型验收。先由平台检查 tgz 并试用,更新沿用包名和 workbenchId;不要自动替换已有插件或数据。不要复制官方内核代码,不使用 Puck。复用系统 Office 与公开领域服务。\n', }}; } diff --git a/packages/plugins/workbenches/src/runtime/development-timing.ts b/packages/plugins/workbenches/src/runtime/development-timing.ts index 0567af8627..f8ee77d6c7 100644 --- a/packages/plugins/workbenches/src/runtime/development-timing.ts +++ b/packages/plugins/workbenches/src/runtime/development-timing.ts @@ -19,7 +19,7 @@ export function timingCategory(name:string,args:unknown,scope:TimingScope):Timin if(['write','edit','apply_patch'].includes(name)&&path&&inside(scope.source,resolve(scope.source,path)))return 'coding'; if(name==='plugin_manager'&&a.action==='install_bundle')return 'installation'; if(name==='workbench_verify_native_installation')return 'installation'; - if(name==='bash'&&typeof a.command==='string'&&(a.command.includes('/scripts/run-delivery.mjs')||/\bnpm\s+run\s+deliver\b/.test(a.command)))return 'delivery'; + if(name==='bash'&&typeof a.command==='string'&&(a.command.includes('/scripts/run-delivery.mjs')||/\b(?:npm|pnpm)\s+run\s+deliver\b|\bnode(?:\.exe)?\s+(?:[^\s]*[/\\])?deliver\.mjs(?:\s|$)/.test(a.command)))return 'delivery'; if(name==='workbench_read_development_context'||name==='read')return 'preparation'; return undefined; } diff --git a/packages/plugins/workbenches/src/runtime/sdk-metadata.ts b/packages/plugins/workbenches/src/runtime/sdk-metadata.ts index 354d868a2a..5b0f9e8194 100644 --- a/packages/plugins/workbenches/src/runtime/sdk-metadata.ts +++ b/packages/plugins/workbenches/src/runtime/sdk-metadata.ts @@ -23,7 +23,7 @@ export async function readWorkbenchSdkMetadata(installAnchor:string){ const delivery=pkg.exports?.['./delivery']==='./scripts/run-delivery.mjs'?await publicFile(pkg.exports['./delivery']):undefined; return {status:'available' as const,packageName:pkg.name as string,version:pkg.version as string,packageDirectory,readme,publicTypes:{host:hostTypes,client:clientTypes}, quickstart,documentationSha256:createHash('sha256').update(documentation).digest('hex'), - ...(delivery?{delivery:{runner:delivery,arguments:[''],execution:'official-development-tool-sandbox',stages:['build','startup-check','pack'],report:'diagnostic-only; not installation or page acceptance',usage:'Execute this shared runner in the official development sandbox. Do not read or copy its implementation unless the command reports a real error.'}}:{}), + ...(delivery?{delivery:{command:process.execPath,runner:delivery,arguments:[''],execution:'official-development-tool-sandbox',stages:['build','startup-check','pack'],report:'diagnostic-only; not installation or page acceptance',usage:'Execute command + runner + arguments in the official development sandbox. The runner uses installed or bundled pnpm JavaScript through Node; system npm is not required. Do not read or copy its implementation unless the command reports a real error.'}}:{}), lookupPolicy:'Use the supplied quickstart and existing scaffold first. Read only public declarations needed by this iteration; recheck on version/fingerprint changes or actual interface errors. Never inspect private bundles.'}; }catch{ // An unavailable metadata lookup is not a claim that the running service is absent. diff --git a/packages/plugins/workbenches/src/runtime/source-checkpoint.ts b/packages/plugins/workbenches/src/runtime/source-checkpoint.ts index d3a495d9be..290519ae1f 100644 --- a/packages/plugins/workbenches/src/runtime/source-checkpoint.ts +++ b/packages/plugins/workbenches/src/runtime/source-checkpoint.ts @@ -1,6 +1,7 @@ import {createHash,randomUUID} from 'node:crypto'; import {mkdir,readdir,realpath,lstat,open,writeFile,rename,rm,readFile} from 'node:fs/promises'; import {constants} from 'node:fs';import {join,relative,isAbsolute,sep} from 'node:path'; +const excludedDirectories=['node_modules','.git','dist','.workdsh-npm-cache','.workdsh-pnpm-store']; /** Source files only; not a live business database backup. */ export async function checkpointWorkbenchSource(source:string,destination:string,authorize:()=>void,signal?:AbortSignal){ const check=()=>{signal?.throwIfAborted();authorize();signal?.throwIfAborted();};check(); @@ -13,7 +14,7 @@ export async function checkpointWorkbenchSource(source:string,destination:string if(++directories>2000||relative.split('/').length>32)throw Error('workbench/source-checkpoint-limit'); check();const directory=join(root,relative);if(await realpath(directory)!==directory)throw Error('workbench/source-directory-outside'); for(const entry of (await readdir(directory,{withFileTypes:true})).sort((a,b)=>a.nameb.name?1:0)){ - if(['node_modules','.git','dist','.workdsh-npm-cache'].includes(entry.name))continue; + if(excludedDirectories.includes(entry.name))continue; if(entry.name==='.workdsh-checkpoint.json')throw Error('workbench/source-reserved-name'); check();const name=relative?relative+'/'+entry.name:entry.name,path=join(root,name); if(entry.isSymbolicLink())throw Error('workbench/source-link-unsupported'); @@ -31,7 +32,7 @@ export async function checkpointWorkbenchSource(source:string,destination:string check();if(++visited>2000||relative.split('/').length>32)throw Error('workbench/source-checkpoint-limit'); const directory=join(root,relative);if(await realpath(directory)!==directory)throw Error('workbench/source-changed'); for(const item of await readdir(directory,{withFileTypes:true})){ - if(['node_modules','.git','dist','.workdsh-npm-cache'].includes(item.name))continue; + if(excludedDirectories.includes(item.name))continue; const name=relative?relative+'/'+item.name:item.name; if(item.isDirectory()){await scan(name);continue;} const entry=expected.get(name);if(!entry||!item.isFile())throw Error('workbench/source-changed'); @@ -47,7 +48,7 @@ export async function checkpointWorkbenchSource(source:string,destination:string await scan('');if(seen!==entries.length)throw Error('workbench/source-changed');check(); } try{ - await walk('');check();await verifySource();const manifest={schemaVersion:1,excludes:['node_modules','.git','dist','.workdsh-npm-cache'],files:entries}; + await walk('');check();await verifySource();const manifest={schemaVersion:1,excludes:excludedDirectories,files:entries}; const hash=createHash('sha256').update(JSON.stringify(manifest)).digest('hex'); await writeFile(join(staging,'.workdsh-checkpoint.json'),JSON.stringify(manifest),{flag:'wx',mode:0o600});check();const directory=join(destination,hash); try{await rename(staging,directory);}catch(cause){ diff --git a/packages/plugins/workbenches/tests/delivery-runner.test.mjs b/packages/plugins/workbenches/tests/delivery-runner.test.mjs index b9e57e63aa..3e605e243d 100644 --- a/packages/plugins/workbenches/tests/delivery-runner.test.mjs +++ b/packages/plugins/workbenches/tests/delivery-runner.test.mjs @@ -4,11 +4,13 @@ import {mkdtemp,mkdir,writeFile,readFile,symlink,rm,readdir} from 'node:fs/promi import {tmpdir} from 'node:os'; import {join,dirname} from 'node:path'; import {fileURLToPath} from 'node:url'; -import {runWorkbenchDelivery} from '../scripts/run-delivery.mjs'; +import {runWorkbenchDelivery,resolveWorkbenchPackageManager} from '../scripts/run-delivery.mjs'; +const pnpmRoot=fileURLToPath(new URL('../../../../apps/web/node_modules/pnpm',import.meta.url)); +const pnpmVersion=JSON.parse(await readFile(join(pnpmRoot,'package.json'),'utf8')).version; async function fixture(failBuild=false,failStartup=false){ const directory=await mkdtemp(join(tmpdir(),'workdsh-delivery-test-')); - await mkdir(join(directory,'dist'));await symlink(fileURLToPath(new URL('../../../../apps/web/node_modules',import.meta.url)),join(directory,'node_modules'),'dir'); - await writeFile(join(directory,'package.json'),JSON.stringify({name:'workdsh-delivery-probe',version:'1.0.0',type:'module',files:['dist'],scripts:{build:'node build.mjs',prepack:'node must-not-run.mjs'},exports:{'.':{default:'./dist/index.js'}},peerDependencies:{'@deepseek-ai/cordis':'4.0.5'},workdsh:{workbench:{id:'probe'}}})); + await mkdir(join(directory,'dist'));await symlink(fileURLToPath(new URL('../../../../apps/web/node_modules',import.meta.url)),join(directory,'node_modules'),process.platform==='win32'?'junction':'dir'); + await writeFile(join(directory,'package.json'),JSON.stringify({name:'workdsh-delivery-probe',version:'1.0.0',type:'module',packageManager:'pnpm@10.0.0',files:['dist'],scripts:{build:'node build.mjs',prepack:'node must-not-run.mjs'},exports:{'.':{default:'./dist/index.js'}},peerDependencies:{'@deepseek-ai/cordis':'4.0.5'},workdsh:{workbench:{id:'probe'}}})); await writeFile(join(directory,'build.mjs'),failBuild?'process.exit(2)':''); await writeFile(join(directory,'dist/index.js'),failStartup?'export function apply(){throw Error("private-error-must-not-leak");}':"export const inject=['workdshWorkbenchRegistration'];export async function apply(ctx){await ctx.get('workdshWorkbenchRegistration').registerManaged(ctx,{id:'probe'},'probe-page');}"); return directory; @@ -29,3 +31,55 @@ test('cancellation does not start the build or pack',async()=>{const dir=await f test('a hanging build times out and cannot reach startup or packaging',async()=>{ const dir=await fixture();let result;try{await writeFile(join(dir,'build.mjs'),'setInterval(()=>{},1000);');result=await runWorkbenchDelivery(dir,{commandTimeoutMs:500});assert.equal(result.status,'failed');assert.equal(result.stages.length,1);assert.equal(result.stages[0].errorCode,'workbench/delivery-command-timeout');assert.equal((await readdir(dir)).some(s=>s.endsWith('.tgz')),false);}finally{await cleanup(dir,result);} }); + +test('a bundled Node and pnpm JS entry deliver with no system npm or pnpm on PATH',async()=>{ + const dir=await fixture(),primary=await mkdtemp(join(tmpdir(),'workdsh primary runtime '));let result; + try{ + await mkdir(join(primary,'dependencies')); + await symlink(pnpmRoot,join(primary,'dependencies/pnpm'),process.platform==='win32'?'junction':'dir'); + await writeFile(join(primary,'runtime.json'),JSON.stringify({pnpm:pnpmVersion})); + await writeFile(join(dir,'build.mjs'),"import assert from 'node:assert/strict';import {writeFileSync} from 'node:fs';assert.equal(process.env.WORKDSH_TEST_SECRET,undefined);assert.match(process.env.npm_execpath,/pnpm\\.(?:cjs|mjs)$/);writeFileSync('build-engine.json',JSON.stringify({node:process.execPath,manager:process.env.npm_execpath}));"); + result=await runWorkbenchDelivery(dir,{environment:{...process.env,PATH:'',DSH_BUNDLED_PRIMARY_RUNTIME:primary,WORKDSH_TEST_SECRET:'must-not-inherit'}}); + assert.equal(result.status,'passed',JSON.stringify(result)); + assert.deepEqual(result.packageManager,{name:'pnpm',version:pnpmVersion,source:'bundled-primary-runtime'}); + assert.equal(JSON.parse(await readFile(join(dir,'build-engine.json'),'utf8')).node,process.execPath); + }finally{await cleanup(dir,result);await rm(primary,{recursive:true,force:true});} +}); + +test('a mismatched bundled pnpm fails before business code runs without falling back to PATH',async()=>{ + const dir=await fixture(),primary=await mkdtemp(join(tmpdir(),'workdsh-pnpm-mismatch-'));let result; + try{ + await mkdir(join(primary,'dependencies'));await symlink(pnpmRoot,join(primary,'dependencies/pnpm'),process.platform==='win32'?'junction':'dir'); + await writeFile(join(primary,'runtime.json'),JSON.stringify({pnpm:'0.0.0'})); + await writeFile(join(dir,'build.mjs'),"import {writeFileSync} from 'node:fs';writeFileSync('must-not-run','bad');"); + result=await runWorkbenchDelivery(dir,{environment:{...process.env,DSH_BUNDLED_PRIMARY_RUNTIME:primary}}); + assert.equal(result.status,'failed');assert.equal(result.stages[0].errorCode,'workbench/delivery-package-manager-invalid'); + assert.equal((await readdir(dir)).includes('must-not-run'),false); + }finally{await cleanup(dir,result);await rm(primary,{recursive:true,force:true});} +}); + +test('the bundled Node sibling layout resolves pnpm when tool environments omit DSH variables',async()=>{ + const root=await mkdtemp(join(tmpdir(),'workdsh-node-sibling-')); + try{ + const source=join(root,'source'),primary=join(root,'primary');await mkdir(source);await mkdir(join(primary,'dependencies'),{recursive:true}); + await symlink(pnpmRoot,join(primary,'dependencies/pnpm'),process.platform==='win32'?'junction':'dir'); + const result=await resolveWorkbenchPackageManager(source,{environment:{PATH:''},nodeExecutable:join(primary,'dependencies/node/bin',process.platform==='win32'?'node.exe':'node')}); + assert.equal(result.name,'pnpm');assert.equal(result.version,pnpmVersion); + }finally{await rm(root,{recursive:true,force:true});} +}); + +test('a timed out build returns even when a detached grandchild holds stdout open',async()=>{ + const dir=await fixture();let result,grandchild; + try{ + await writeFile(join(dir,'build.mjs'),"import {spawn} from 'node:child_process';import {writeFileSync} from 'node:fs';const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{detached:true,stdio:['ignore',1,2]});writeFileSync('grandchild.pid',String(child.pid));setInterval(()=>{},1000);"); + const start=Date.now();result=await runWorkbenchDelivery(dir,{commandTimeoutMs:1000}); + assert.equal(result.status,'failed');assert.equal(result.stages[0].errorCode,'workbench/delivery-command-timeout'); + assert.ok(Date.now()-start<5000,'inherited stdio must not keep delivery running indefinitely'); + grandchild=Number(await readFile(join(dir,'grandchild.pid'),'utf8')); + assert.equal(result.candidate,undefined); + }finally{ + if(!grandchild)try{grandchild=Number(await readFile(join(dir,'grandchild.pid'),'utf8'));}catch{} + if(grandchild)try{process.kill(grandchild,'SIGKILL');}catch{} + await cleanup(dir,result); + } +}); diff --git a/packages/plugins/workbenches/tests/development-starter.test.mjs b/packages/plugins/workbenches/tests/development-starter.test.mjs index 64e1669187..6dadd2256c 100644 --- a/packages/plugins/workbenches/tests/development-starter.test.mjs +++ b/packages/plugins/workbenches/tests/development-starter.test.mjs @@ -4,7 +4,7 @@ test('generated starter builds actual Host and Client artifacts with stable pack const dir=await mkdtemp(join(tmpdir(),'workdsh-starter-')); try{ const descriptor={id:'probe.starter',title:'骨架验证',description:'',protocolVersion:'1.0',layout:'full-page',hostCompatibility:{sdk:'^1.0.0',harness:'0.2.1-rc.1'},capabilities:[]}; - const starter=createWorkbenchStarter('workdsh-probe-starter',descriptor);assert.match(starter.files['.npmrc'],/legacy-peer-deps=true/);assert.match(starter.files['.npmrc'],/cache=\.workdsh-npm-cache/);assert.match(starter.manifest.peerDependencies['workdsh-plugin-workbenches'],/^\^3\.0\.0-alpha\./);await writeFile(join(dir,'package.json'),JSON.stringify(starter.manifest));for(const [name,content] of Object.entries(starter.files))await writeFile(join(dir,name),content); + const starter=createWorkbenchStarter('workdsh-probe-starter',descriptor);assert.match(starter.files['.npmrc'],/auto-install-peers=false/);assert.match(starter.files['.npmrc'],/store-dir=\.workdsh-pnpm-store/);assert.match(starter.manifest.peerDependencies['workdsh-plugin-workbenches'],/^\^3\.0\.0-alpha\./);await writeFile(join(dir,'package.json'),JSON.stringify(starter.manifest));for(const [name,content] of Object.entries(starter.files))await writeFile(join(dir,name),content); await symlink(fileURLToPath(new URL('../../../../apps/web/node_modules',import.meta.url)),join(dir,'node_modules'),'dir'); const result=spawnSync(process.execPath,['build.mjs'],{cwd:dir,encoding:'utf8'});assert.equal(result.status,0,result.stderr); // Public SDK imports must stay external, even with no locally resolvable SDK. diff --git a/packages/plugins/workbenches/tests/development-timing.test.mjs b/packages/plugins/workbenches/tests/development-timing.test.mjs index c654f48a95..b37ea67ba6 100644 --- a/packages/plugins/workbenches/tests/development-timing.test.mjs +++ b/packages/plugins/workbenches/tests/development-timing.test.mjs @@ -6,4 +6,4 @@ const {WorkbenchDevelopmentTiming,timingCategory,timingScopeKey}=await import('d async function fixture(){const root=await realpath(await mkdtemp(join(tmpdir(),'workdsh-timing-')));let allowed=true;const scope={key:timingScopeKey('actor-a-development-a','iteration-a'),source:'/owned/source',sdkDirectory:'/owned/sdk',guard:async()=>{if(!allowed)throw Error('access-denied');}};return {root,scope,store:new WorkbenchDevelopmentTiming(root),deny:()=>allowed=false,close:()=>rm(root,{recursive:true,force:true})};} test('persists only Host timing facts and separates iterations; restart marks unfinished calls interrupted',async()=>{const f=await fixture();try{const end=await f.store.begin(f.scope,'sdk');await end(false);await end(true);await f.store.begin(f.scope,'coding');const report=await f.store.read(f.scope);assert.equal(report.events.length,2);assert.equal(report.events[0].status,'passed');assert.ok(report.events[0].durationMs>=0);assert.equal(report.events[1].status,'running');const restarted=await new WorkbenchDevelopmentTiming(f.root).read(f.scope);assert.equal(restarted.events[1].status,'interrupted');assert.equal(restarted.events[1].durationMs,undefined);assert.equal((await f.store.read({...f.scope,key:timingScopeKey('actor-a-development-a','iteration-b')})).events.length,0);const disk=await readFile(join(f.root,(await readdir(f.root))[0]),'utf8');assert.doesNotMatch(disk,/owned|credential|command|arguments/);}finally{await f.close();}}); test('revoked access cannot read or finish a timing write',async()=>{const f=await fixture();try{const end=await f.store.begin(f.scope,'installation');f.deny();await assert.rejects(end(false),/access-denied/);await assert.rejects(f.store.read(f.scope),/access-denied/);}finally{await f.close();}}); -test('classifies SDK reads and business writes without classifying unrelated files or arbitrary installation commands',()=>{const scope={source:'/owned/source',sdkDirectory:'/owned/sdk'};assert.equal(timingCategory('read',{file_path:'/owned/sdk/dist/client.d.ts'},scope),'sdk');assert.equal(timingCategory('write',{file_path:'client.jsx'},scope),'coding');assert.equal(timingCategory('write',{path:'../secret'},scope),undefined);assert.equal(timingCategory('plugin_manager',{action:'install_bundle',target:'candidate.tgz'},scope),'installation');assert.equal(timingCategory('plugin_manager',{action:'remove'},scope),undefined);assert.equal(timingCategory('bash',{command:'npm run deliver'},scope),'delivery');assert.equal(timingCategory('bash',{command:'cat secret'},scope),undefined);}); +test('classifies SDK reads and business writes without classifying unrelated files or arbitrary installation commands',()=>{const scope={source:'/owned/source',sdkDirectory:'/owned/sdk'};assert.equal(timingCategory('read',{file_path:'/owned/sdk/dist/client.d.ts'},scope),'sdk');assert.equal(timingCategory('write',{file_path:'client.jsx'},scope),'coding');assert.equal(timingCategory('write',{path:'../secret'},scope),undefined);assert.equal(timingCategory('plugin_manager',{action:'install_bundle',target:'candidate.tgz'},scope),'installation');assert.equal(timingCategory('plugin_manager',{action:'remove'},scope),undefined);assert.equal(timingCategory('bash',{command:'npm run deliver'},scope),'delivery');assert.equal(timingCategory('bash',{command:'node deliver.mjs'},scope),'delivery');assert.equal(timingCategory('bash',{command:'pnpm run deliver'},scope),'delivery');assert.equal(timingCategory('bash',{command:'cat secret'},scope),undefined);}); diff --git a/packages/plugins/workbenches/tests/sdk-metadata.test.mjs b/packages/plugins/workbenches/tests/sdk-metadata.test.mjs index 53e3cffe37..58180ff930 100644 --- a/packages/plugins/workbenches/tests/sdk-metadata.test.mjs +++ b/packages/plugins/workbenches/tests/sdk-metadata.test.mjs @@ -5,7 +5,7 @@ test('resolves installed public exports without importing executable SDK code',a const metadata={name:'workdsh-plugin-workbenches',version:'3.0.0-alpha.40',exports:{'./package.json':'./package.json','./delivery':'./scripts/run-delivery.mjs','.':{types:'./dist/index.d.ts',default:'./dist/index.js'},'./client':{types:'./dist/client.d.ts',default:'./dist/client.js'}}}; await writeFile(join(pkg,'package.json'),JSON.stringify(metadata));await mkdir(join(pkg,'scripts'));await writeFile(join(pkg,'scripts/run-delivery.mjs'),'throw Error("must-not-execute-delivery");');await writeFile(join(pkg,'README.md'),'# Public SDK\n## 业务包最短接入\nuse public interfaces\n## Private appendix\nnot in quickstart');for(const name of ['index.d.ts','client.d.ts'])await writeFile(join(pkg,'dist',name),'export {};');await writeFile(join(pkg,'dist/index.js'),'throw Error("metadata-must-not-execute-sdk");'); const value=await readWorkbenchSdkMetadata(anchor);assert.equal(value.status,'available');assert.equal(value.version,metadata.version);assert.equal(value.readme,join(value.packageDirectory,'README.md'));assert.equal(value.publicTypes.client,join(value.packageDirectory,'dist/client.d.ts')); - assert.match(value.quickstart,/use public interfaces/);assert.doesNotMatch(value.quickstart,/Private appendix/);assert.match(value.documentationSha256,/^[a-f0-9]{64}$/);assert.equal(value.delivery.runner,join(value.packageDirectory,'scripts/run-delivery.mjs'));assert.deepEqual(value.delivery.stages,['build','startup-check','pack']); + assert.match(value.quickstart,/use public interfaces/);assert.doesNotMatch(value.quickstart,/Private appendix/);assert.match(value.documentationSha256,/^[a-f0-9]{64}$/);assert.equal(value.delivery.runner,join(value.packageDirectory,'scripts/run-delivery.mjs'));assert.deepEqual(value.delivery.stages,['build','startup-check','pack']);assert.equal(value.delivery.command,process.execPath); const outside=join(root,'outside.d.ts');await writeFile(outside,'private');await rm(join(pkg,'dist/client.d.ts'));await symlink(outside,join(pkg,'dist/client.d.ts'));assert.equal((await readWorkbenchSdkMetadata(anchor)).status,'unavailable','public exports cannot advertise a symlink escaping the installed package'); }finally{await rm(root,{recursive:true,force:true});}}); test('missing SDK reports metadata unavailability without asserting runtime absence',async()=>{const root=await mkdtemp(join(tmpdir(),'workdsh-sdk-absent-'));try{const anchor=join(root,'package.json');await writeFile(anchor,'{}');const result=await readWorkbenchSdkMetadata(anchor);assert.equal(result.status,'unavailable');assert.equal(result.errorCode,'workbench/sdk-metadata-unavailable');assert.equal(JSON.stringify(result).includes(root),false);}finally{await rm(root,{recursive:true,force:true});}}); diff --git a/packages/plugins/workbenches/tests/source-checkpoint.test.mjs b/packages/plugins/workbenches/tests/source-checkpoint.test.mjs index 9343dc1814..5ae4714070 100644 --- a/packages/plugins/workbenches/tests/source-checkpoint.test.mjs +++ b/packages/plugins/workbenches/tests/source-checkpoint.test.mjs @@ -26,3 +26,14 @@ test('change during checkpoint creation rejects publication and cleans staging', }),/source-changed/);assert.equal(changed,true);assert.deepEqual(await readdir(backups),[]); }finally{await rm(root,{recursive:true,force:true});} }); + +test('package manager caches do not enter source snapshots or change their identity',async()=>{ + const root=await realpath(await mkdtemp(join(tmpdir(),'source-checkpoint-cache-'))),source=join(root,'source'),backups=join(root,'backups');await mkdir(source); + try{ + await writeFile(join(source,'index.js'),'source'); + for(const cache of ['.workdsh-npm-cache','.workdsh-pnpm-store']){await mkdir(join(source,cache));await writeFile(join(source,cache,'entry'),'old cache');} + const before=await save(source,backups,()=>{});assert.equal(before.fileCount,1);assert.equal(before.byteLength,6); + for(const cache of ['.workdsh-npm-cache','.workdsh-pnpm-store'])await writeFile(join(source,cache,'entry'),'new generated cache'); + const after=await save(source,backups,()=>{});assert.equal(after.sha256,before.sha256);assert.deepEqual((await readdir(before.directory)).sort(),['.workdsh-checkpoint.json','index.js']); + }finally{await rm(root,{recursive:true,force:true});} +}); diff --git a/scripts/ci-scope.test.mjs b/scripts/ci-scope.test.mjs new file mode 100644 index 0000000000..c4af9ecbfe --- /dev/null +++ b/scripts/ci-scope.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { test } from 'node:test' + +const root = resolve(import.meta.dirname, '..') +const workflow = readFileSync(resolve(root, '.github/workflows/ci.yml'), 'utf8') +// Exercise the workflow's actual shell conditions, rather than maintaining +// another JavaScript copy of the path expressions. +const conditions = workflow.split('\n').filter(line => line.trimStart().startsWith('if [[') + && line.includes('<<< "$changed_paths"; then ')) +assert.equal(conditions.length, 2, 'CI must classify both Web and Desktop scope') +const shell = ['set -euo pipefail', ...conditions, 'printf "web=%s\\ndesktop=%s\\n" "$web" "$desktop"'].join('\n') + +function scopes(paths) { + const product = spawnSync(process.execPath, ['scripts/classify-ci-changes.mjs'], { + cwd: root, input: paths.join('\0') + '\0', encoding: 'utf8', + }) + assert.equal(product.status, 0, product.stderr) + const result = spawnSync('bash', ['-c', shell], { + cwd: root, + env: { ...process.env, changed_paths: paths.join('\n'), product: product.stdout.trim() }, + encoding: 'utf8', + }) + assert.equal(result.status, 0, result.stderr) + return result.stdout.trim().split('\n') +} + +for (const extension of ['mjs', 'json', 'd.mts']) { + const path = `scripts/native-workbench-frame-patch.${extension}` + test(`shared adapter ${extension} changes trigger Web integration and Desktop packaging`, () => { + assert.deepEqual(scopes([path]), ['web=true', 'desktop=true']) + const product = spawnSync(process.execPath, ['scripts/classify-ci-changes.mjs'], { + cwd: root, input: path + '\0', encoding: 'utf8', + }) + assert.equal(product.status, 0, product.stderr) + assert.equal(product.stdout.trim(), 'true') + }) +} + +test('shared and Web-only changes retain their existing delivery scope', () => { + for (const path of ['packages/plugins/workbenches/src/client.tsx', 'profiles/shared/workdsh-features.json']) { + assert.deepEqual(scopes([path]), ['web=true', 'desktop=true']) + } + assert.deepEqual(scopes(['apps/web/scripts/start-preview.mjs']), ['web=true', 'desktop=false']) + assert.deepEqual(scopes(['scripts/native-workbench-frame-patch-extra.mjs']), ['web=false', 'desktop=true']) +}) + + +test('README, bilingual record and release note changes skip Desktop packaging', () => { + const paths = ['README.md', 'README.zh-CN.md', 'README.i18n.yaml', '.github/release-notes/desktop-v3.0.0-alpha.1.md'] + for (const path of paths) assert.deepEqual(scopes([path]), ['web=false', 'desktop=false']) + assert.deepEqual(scopes(paths), ['web=false', 'desktop=false']) + assert.deepEqual(scopes(['apps/desktop/README.md', 'apps/desktop/README.i18n.yaml']), ['web=false', 'desktop=false']) + assert.deepEqual(scopes(['README.md', 'scripts/native-workbench-frame-patch.mjs']), ['web=true', 'desktop=true']) +}) + +test('tag and manual runs still force Desktop release checks', () => { + const start = workflow.indexOf(' if [[ "$EVENT_NAME" == "workflow_dispatch"') + const end = workflow.indexOf(' echo "product=$product"', start) + assert.ok(start >= 0 && end > start) + const classify = workflow.slice(start, end) + for (const event of [{ EVENT_NAME: 'workflow_dispatch', REF_TYPE: 'branch' }, { EVENT_NAME: 'push', REF_TYPE: 'tag' }]) { + const result = spawnSync('bash', ['-c', ['set -euo pipefail', classify, + 'printf "product=%s\\ndesktop=%s\\nweb=%s\\n" "$product" "$desktop" "$web"'].join('\n')], { + cwd: root, env: { ...process.env, ...event, BASE_SHA: '', HEAD_SHA: '' }, encoding: 'utf8', + }) + assert.equal(result.status, 0, result.stderr) + assert.equal(result.stdout, 'product=true\ndesktop=true\nweb=false\n') + } +}) diff --git a/scripts/native-workbench-frame-patch.d.mts b/scripts/native-workbench-frame-patch.d.mts new file mode 100644 index 0000000000..1b1cc7226d --- /dev/null +++ b/scripts/native-workbench-frame-patch.d.mts @@ -0,0 +1,5 @@ +export function patchNativeWorkbenchFrame(source: string): string +export function ensureNativeWorkbenchFrame(profile: string, options?: { rollback?: boolean }): Promise +export function verifyNativeWorkbenchFrame(profile: string): Promise<{ + package: string; version: string; file: string; sha256: string +}> diff --git a/apps/web/scripts/native-workbench-frame-patch.json b/scripts/native-workbench-frame-patch.json similarity index 100% rename from apps/web/scripts/native-workbench-frame-patch.json rename to scripts/native-workbench-frame-patch.json diff --git a/scripts/native-workbench-frame-patch.mjs b/scripts/native-workbench-frame-patch.mjs new file mode 100644 index 0000000000..edf79e4948 --- /dev/null +++ b/scripts/native-workbench-frame-patch.mjs @@ -0,0 +1,46 @@ +import {readFile,writeFile,rename,rm,stat} from 'node:fs/promises'; +import {createHash,randomUUID} from 'node:crypto'; +import {createRequire} from 'node:module'; +import {dirname,join} from 'node:path'; +import {fileURLToPath} from 'node:url'; +const spec=JSON.parse(await readFile(new URL('./native-workbench-frame-patch.json',import.meta.url),'utf8')); +const hash=value=>createHash('sha256').update(value).digest('hex'); +export function patchNativeWorkbenchFrame(source){ + const digest=hash(source);if(digest===spec.patchedSha256)return source; + if(digest!==spec.originalSha256)throw Error('Native workbench frame: unsupported runtime bytes; refusing to patch.'); + let output=source; + for(const [before,after] of spec.replacements){if(output.split(before).length!==2)throw Error('Native frame patch anchor mismatch');output=output.replace(before,after);} + if(hash(output)!==spec.patchedSha256)throw Error('Native frame patch result mismatch');return output; +} +// Replace this installation's directory entry, never mutate a pnpm hardlink +// in place (which could otherwise change a different Profile or the store). +async function replaceRuntimeFile(target,expected,next){ + const temporary=`${target}.workdsh-frame-${randomUUID()}`; + const mode=(await stat(target)).mode&0o777; + try{ + await writeFile(temporary,next,{flag:'wx',mode}); + if(hash(await readFile(target,'utf8'))!==hash(expected))throw Error('Native frame target changed during update'); + await rename(temporary,target); + }finally{await rm(temporary,{force:true});} +} +export async function ensureNativeWorkbenchFrame(profile,{rollback=false}={}){ + const require=createRequire(join(profile,'package.json')); + const path=require.resolve(`${spec.package}/package.json`),manifest=JSON.parse(await readFile(path,'utf8')); + if(manifest.version!==spec.version)throw Error('Native workbench frame: runtime version mismatch'); + const target=join(dirname(path),spec.file),source=await readFile(target,'utf8'); + if(rollback){if(hash(source)===spec.originalSha256)return;if(hash(source)!==spec.patchedSha256)throw Error('Cannot roll back unknown runtime');let original=source;for(const [before,after] of [...spec.replacements].reverse())original=original.replace(after,before);if(hash(original)!==spec.originalSha256)throw Error('Rollback mismatch');await replaceRuntimeFile(target,source,original);return;} + const patched=patchNativeWorkbenchFrame(source); + if(patched!==source)await replaceRuntimeFile(target,source,patched); +} +/** Read the installed artifact, rather than trusting a preparation receipt. */ +export async function verifyNativeWorkbenchFrame(profile){ + const require=createRequire(join(profile,'package.json')); + const path=require.resolve(`${spec.package}/package.json`),manifest=JSON.parse(await readFile(path,'utf8')); + if(manifest.version!==spec.version)throw Error('Native workbench frame: runtime version mismatch'); + const target=join(dirname(path),spec.file),digest=hash(await readFile(target,'utf8')); + if(digest!==spec.patchedSha256)throw Error(digest===spec.originalSha256 + ?'Native workbench frame: authorized adapter is missing from the packaged runtime' + :'Native workbench frame: unsupported packaged runtime bytes'); + return {package:spec.package,version:spec.version,file:spec.file,sha256:digest}; +} +if(process.argv[1]===fileURLToPath(import.meta.url)){if(!process.argv[2])throw Error('Profile path required');await ensureNativeWorkbenchFrame(process.argv[2],{rollback:process.argv.includes('--rollback')});} From e10141004aa03f5d6b7d282a47bab3de202adabc Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Sun, 18 Oct 2026 12:36:41 +0800 Subject: [PATCH 2/7] Verify complete packaged Host and preserve workspace origins on startup --- .../release-notes/desktop-v3.0.0-alpha.1.md | 4 +- .../desktop/scripts/packaged-host-smoke.d.mts | 7 ++ apps/desktop/scripts/packaged-host-smoke.mjs | 78 +++++++++++++ .../scripts/verify-installation-anchor.mjs | 52 ++++----- .../desktop/scripts/verify-workdsh-carrier.ts | 4 + apps/desktop/src/runtime-diagnostics.ts | 24 +++- apps/desktop/src/runtime-port.ts | 106 ++++++++++++++++++ apps/desktop/src/workdsh-main.ts | 31 ++--- .../desktop/tests/packaged-host-smoke.spec.ts | 50 +++++++++ .../desktop/tests/runtime-diagnostics.spec.ts | 17 ++- apps/desktop/tests/runtime-port.spec.ts | 98 ++++++++++++++++ apps/web/pnpm-lock.yaml | 6 + packages/plugins/workbenches/package.json | 4 + .../tests/runtime-dependency-closure.test.mjs | 105 +++++++++++++++++ 14 files changed, 542 insertions(+), 44 deletions(-) create mode 100644 apps/desktop/scripts/packaged-host-smoke.d.mts create mode 100644 apps/desktop/scripts/packaged-host-smoke.mjs create mode 100644 apps/desktop/src/runtime-port.ts create mode 100644 apps/desktop/tests/packaged-host-smoke.spec.ts create mode 100644 apps/desktop/tests/runtime-port.spec.ts create mode 100644 packages/plugins/workbenches/tests/runtime-dependency-closure.test.mjs diff --git a/.github/release-notes/desktop-v3.0.0-alpha.1.md b/.github/release-notes/desktop-v3.0.0-alpha.1.md index f4d68784b2..2efb3b0135 100644 --- a/.github/release-notes/desktop-v3.0.0-alpha.1.md +++ b/.github/release-notes/desktop-v3.0.0-alpha.1.md @@ -8,9 +8,11 @@ - Desktop 与 Web 固定到同一 DSH 版本。官方运行依赖从校验过的发行文件提取,桌面端继续使用完整官方客户端。 - 桌面启动失败时显示实际原因与脱敏后的近期运行输出,并保留本机错误记录;进程清理失败不会再覆盖启动原因。 +- 新工作区在默认本机端口被占用时可选择独立端口;已有工作区保留原地址,避免页面数据因地址变化而看似丢失。错误弹框显示简短原因,完整脱敏输出保存在本机日志中。 - 基础 Desktop Profile 加入工作台平台,与连接器、专家、资料库、项目和 Skill 共六个默认 WorkDSH bundle。工作台统一需求确认、开发、启动检查、安装状态核对与试用验收,记录阶段耗时,支持中断后恢复。 - 视频展示使用平台生成的旅行应用,可持续管理多次旅行,每次有独立行程、预算和清单;基础安装包不预装该业务示例。 - 改进发行脚本的首次依赖安装顺序,检查 Profile 依赖、平台运行文件与归档完整性。 +- 共用交付脚本使用随包 Node 和 pnpm,不依赖系统 npm;构建不自动切换包管理器或安装依赖,打包不重复执行构建脚本。 - 企业连接继续作为独立插件下载并显式安装,基础安装包不预装企业账号与协作插件。 ## 发行文件 @@ -22,7 +24,7 @@ ## 验证范围 -发行门禁覆盖源码构建、类型检查、自动化回归和 Windows x64、macOS x64/arm64 打包。自动打包检查不代表各平台全部真实交互与企业后台均已验收;Web 预览也不能代替 Desktop 打包运行时验收。 +发行门禁覆盖源码构建、类型检查、自动化回归和 Windows x64、macOS x64/arm64 打包,并在实际随包运行时中检查完整 Host、六个必需产品服务及客户端资源。自动检查不代表各平台全部真实交互与企业后台均已验收;Web 预览也不能代替 Desktop 打包运行时验收。 工作台外框适配限定官方 DSH `0.2.1-rc.1` 的两个外框接缝,并校验原始文件与适配后文件的摘要;未知版本或文件内容不匹配时停止处理。官方 Header、对话视图、Composer 与右侧栏的完整组件及状态归属保持不变。该适配不代表所有业务工作台已经完成界面与数据验收。 diff --git a/apps/desktop/scripts/packaged-host-smoke.d.mts b/apps/desktop/scripts/packaged-host-smoke.d.mts new file mode 100644 index 0000000000..949cd19ad6 --- /dev/null +++ b/apps/desktop/scripts/packaged-host-smoke.d.mts @@ -0,0 +1,7 @@ +import type { ChildProcess } from 'node:child_process' +export const PRODUCT_SERVICES: Readonly> +export function inspectStartedHost(ctx: { fiber: { state: number }; get(name: string): unknown; registry: { values(): Iterable<{ name?: string; fibers: Iterable<{ state: number }> }> } }, products: Readonly>, platformServices: readonly string[]): { kind: string; products: string[]; services: string[]; installAnchor: string } +export function prepareHostSmokeProbe(home: string): string +export function hostSmokeEnvironment(home: string, primary: string, executable: string, parent?: NodeJS.ProcessEnv): NodeJS.ProcessEnv +export function scrubHostDiagnostics(value: string): string +export function waitForPackagedHostReady(child: ChildProcess, options?: { timeoutMs?: number }): { ready: Promise<{ url: string; receipt: { kind: string; products: string[]; services: string[]; installAnchor: string }; diagnostics(): string }>; diagnostics(): string } diff --git a/apps/desktop/scripts/packaged-host-smoke.mjs b/apps/desktop/scripts/packaged-host-smoke.mjs new file mode 100644 index 0000000000..9637c259fc --- /dev/null +++ b/apps/desktop/scripts/packaged-host-smoke.mjs @@ -0,0 +1,78 @@ +import { writeFileSync } from 'node:fs' +import { dirname, join, delimiter } from 'node:path' +import { PRODUCT_PACKAGES } from './workdsh-package-boundary.mjs' + +export const PRODUCT_SERVICES = Object.freeze({ + 'workdsh-plugin-connectors': 'workdshConnectors', + 'workdsh-plugin-experts': 'workdshExperts', + 'workdsh-plugin-library': 'workdshLibrary', + 'workdsh-plugin-projects': 'workdshProjects', + 'workdsh-plugin-skills': 'workdshSkills', + 'workdsh-plugin-workbenches': 'workdshWorkbenchRegistration', +}) +const REQUIRED_PLATFORM_SERVICES = ['profileContext', 'loader', 'storageDomain', 'connection', 'workdshIdentity', 'workdshAccess', 'sessionController', 'workspaceController', 'tools'] + +/** Inspect mandatory product fibers only. Optional official/model plugins may + * legitimately remain lazy or unconfigured in a fresh personal installation. */ +export function inspectStartedHost(ctx, products, platformServices) { + if (ctx.fiber.state !== 2) throw new Error('Packaged Host root did not finish initialization') + const profile = ctx.get('profileContext') + const selected = profile?.startedBundles ?? [] + const runtimes = [...ctx.registry.values()] + for (const [name, service] of Object.entries(products)) { + if (!selected.includes(name)) throw new Error('Packaged Host did not start selected product: ' + name) + if (!ctx.get(service)) throw new Error('Packaged Host service is unavailable: ' + service) + const runtimeName = name === 'workdsh-plugin-workbenches' ? 'workdsh-workbenches' : name + const fibers = runtimes.filter(runtime => runtime.name === runtimeName).flatMap(runtime => [...runtime.fibers]) + if (!fibers.length || fibers.some(fiber => fiber.state !== 2)) throw new Error('Packaged Host product initialization is incomplete: ' + name) + } + for (const service of platformServices) if (!ctx.get(service)) throw new Error('Packaged Host platform service is unavailable: ' + service) + return { kind: 'workdsh-packaged-host-ready', products: Object.keys(products).sort(), services: Object.values(products).sort(), installAnchor: profile.installAnchor } +} + +/** A temporary owned probe observes the official appReady public contract; + * the product's officialHostLauncher and installed runtime remain unchanged. */ +export function prepareHostSmokeProbe(home) { + if (JSON.stringify(Object.keys(PRODUCT_SERVICES).sort()) !== JSON.stringify([...PRODUCT_PACKAGES].sort())) throw new Error('Host smoke product service map differs from the release inventory') + const probe = join(home, '.workdsh-host-smoke-probe.mjs') + const source = `const inspect = ${inspectStartedHost.toString()};\nexport const name='workdsh-packaged-host-smoke';\nexport const inject=${JSON.stringify(['appReady', ...REQUIRED_PLATFORM_SERVICES, ...Object.values(PRODUCT_SERVICES)])};\nexport function apply(ctx){return ctx.effect(()=>ctx.get('appReady').onReady(()=>{const receipt=inspect(ctx,${JSON.stringify(PRODUCT_SERVICES)},${JSON.stringify(REQUIRED_PLATFORM_SERVICES)});process.send?.(receipt);}),'packaged-host-readiness');}\n` + writeFileSync(probe, source, { mode: 0o600 }) + const patch = join(home, '.headless-host-smoke.patch.yml') + writeFileSync(patch, JSON.stringify([{ id: 'webserver', config: { host: '127.0.0.1', port: 0 } }, { insert: [{ id: 'workdsh-packaged-host-smoke', name: probe }] }]), { mode: 0o600 }) + return patch +} + +export function hostSmokeEnvironment(home, primary, executable, parent = process.env) { + const env = {} + for (const key of ['SystemRoot', 'WINDIR', 'ComSpec', 'PATHEXT', 'LANG', 'LC_ALL', 'TZ']) if (parent[key]) env[key] = parent[key] + Object.assign(env, { PATH: dirname(executable) + delimiter + (parent.PATH ?? ''), HOME: home, USERPROFILE: home, + TMPDIR: home, TMP: home, TEMP: home, APPDATA: join(home, 'appdata'), LOCALAPPDATA: join(home, 'localappdata'), + DSH_HOME: home, DSH_AGENTS_HOME: join(home, 'agents'), DSH_BUNDLED_PRIMARY_RUNTIME: primary, DSH_TELEMETRY_DISABLED: '1' }) + return env +} + +export const scrubHostDiagnostics = value => value.replace(/([?&]token=)[^\s\u001b"']+/gu, '$1[redacted]') + +/** Both full official app readiness and mandatory services must settle. An + * HTTP listening message or manager-only bundle inventory is insufficient. */ +export function waitForPackagedHostReady(child, { timeoutMs = 90_000 } = {}) { + let output = '', url, receipt + const diagnostics = () => scrubHostDiagnostics(output).slice(-6000) + const ready = new Promise((accept, reject) => { + const finish = () => { if (url && receipt) { clearTimeout(timer); accept({ url, receipt, diagnostics }) } } + const timer = setTimeout(() => reject(new Error('Packaged Host did not finish mandatory service initialization: ' + diagnostics())), timeoutMs) + const read = chunk => { + output = (output + chunk.toString()).slice(-64_000) + const failure = output.match(/(?:failed to import|cannot find (?:module|package)|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND)[^\n]{0,1000}/iu) + if (failure) { clearTimeout(timer); reject(new Error('Packaged Host startup dependency failed: ' + scrubHostDiagnostics(failure[0]) + '\n' + diagnostics())); return } + const match = output.match(/http:\/\/(?:127\.0\.0\.1|localhost):\d+\/[^\s\u001b]*\?token=[^\s\u001b]+/u) + if (match) url = match[0] + finish() + } + child.stdout.on('data', read); child.stderr.on('data', read) + child.on('message', message => { if (message?.kind === 'workdsh-packaged-host-ready') { receipt = message; finish() } }) + child.once('error', error => { clearTimeout(timer); reject(error) }) + child.once('exit', code => { clearTimeout(timer); reject(new Error('Packaged Host exited before readiness (' + code + '): ' + diagnostics())) }) + }) + return { ready, diagnostics } +} diff --git a/apps/desktop/scripts/verify-installation-anchor.mjs b/apps/desktop/scripts/verify-installation-anchor.mjs index d7d3f3b8a4..2ee3478668 100644 --- a/apps/desktop/scripts/verify-installation-anchor.mjs +++ b/apps/desktop/scripts/verify-installation-anchor.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** Boot the public official Profile API with release resources and no local node_modules. */ import { spawn, spawnSync } from 'node:child_process' -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,6 +9,7 @@ import { materializeRuntimeProfile, officialHostLauncher } from '../src/local-ru import { DSH_VERSION } from './runtime-version.mjs' import { PRODUCT_PACKAGES } from './workdsh-package-boundary.mjs' import { verifyOfficialWebPackages } from './verify-profile-release.mjs' +import { prepareHostSmokeProbe, hostSmokeEnvironment, waitForPackagedHostReady, scrubHostDiagnostics as scrub } from './packaged-host-smoke.mjs' const runtime = resolve(process.argv[2] ?? fileURLToPath(new URL('../build/workdsh-runtime', import.meta.url))) const source = join(runtime, 'profiles/workdsh') @@ -22,32 +23,21 @@ verifyOfficialWebPackages(release, source) const home = mkdtempSync(join(tmpdir(), 'workdsh-anchor-check-')) let child let finished -let output = '' -const scrub = value => value.replace(/\?token=[^\s]+/gu, '?token=[redacted]') +let diagnostics = () => '' try { const { profile } = materializeRuntimeProfile(source, home) if (existsSync(join(profile, 'node_modules'))) throw new Error('Fresh Profile unexpectedly has its own dependency tree') const inventory = spawnSync(executable, [fileURLToPath(new URL('./verify-product-plugin-inventory.mjs', import.meta.url)), runtime, profile], { encoding: 'utf8', timeout: 30_000 }) if (inventory.error || inventory.status !== 0) throw new Error('Writable Profile product inventory failed: ' + (inventory.error?.message ?? scrub(inventory.stdout + inventory.stderr))) - const patch = join(home, '.headless-port.patch.yml') - writeFileSync(patch, '- id: webserver\n config:\n host: 127.0.0.1\n port: 0\n', { mode: 0o600 }) + const patch = prepareHostSmokeProbe(home) const launcher = officialHostLauncher(source, home, executable, pnpm, [join(source, 'cordis.patch.yml'), patch]) - const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !/(?:KEY|SECRET|TOKEN|PASSWORD|DSH_|WORKDSH_)/iu.test(key))) - Object.assign(env, { HOME: home, USERPROFILE: home, DSH_HOME: home, DSH_AGENTS_HOME: join(home, 'agents'), DSH_BUNDLED_PRIMARY_RUNTIME: primary, DSH_TELEMETRY_DISABLED: '1' }) - const ready = new Promise((resolveReady, reject) => { - child = spawn(executable, [launcher, '--no-open'], { env, cwd: home, stdio: ['ignore', 'pipe', 'pipe'] }) - const timer = setTimeout(() => reject(new Error('Official anchored runtime did not become ready: ' + scrub(output).slice(-5000))), 90_000) - finished = new Promise(resolveExit => child.once('close', resolveExit)) - const read = chunk => { - output = (output + chunk.toString()).slice(-64_000) - const match = output.match(/http:\/\/(?:127\.0\.0\.1|localhost):\d+\/[^\s\u001b]*\?token=[^\s\u001b]+/u) - if (match) { clearTimeout(timer); resolveReady(match[0]) } - } - child.stdout.on('data', read); child.stderr.on('data', read) - child.once('error', error => { clearTimeout(timer); reject(error) }) - child.once('exit', code => { clearTimeout(timer); reject(new Error('Official runtime exited before readiness (' + code + '): ' + scrub(output).slice(-5000))) }) - }) - const url = await ready + const env = hostSmokeEnvironment(home, primary, executable) + child = spawn(executable, [launcher, '--no-open'], { env, cwd: home, stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }) + finished = new Promise(resolveExit => child.once('close', resolveExit)) + const startup = waitForPackagedHostReady(child) + diagnostics = startup.diagnostics + const { url, receipt } = await startup.ready + if (receipt.installAnchor !== join(source, 'profile-installation.json')) throw new Error('Host smoke used another installation anchor') const authorize = await fetch(url, { redirect: 'manual', signal: AbortSignal.timeout(20_000) }) if (authorize.status !== 303) throw new Error('Official process-token login did not exchange for a browser cookie') const target = new URL(authorize.headers.get('location'), url) @@ -64,23 +54,33 @@ try { for (const client of release.officialWeb.clients.filter(row => !row.conditional)) { if (!ids.has(client.package)) throw new Error('Unconditional official Web client missing at runtime: ' + client.package) } - for (const name of [...PRODUCT_PACKAGES, 'workdsh-bundle']) if (!ids.has(name)) throw new Error('Owned client missing at runtime: ' + name + '; owned graph=' + [...ids].filter(row => row.startsWith('workdsh-')).join(',') + '; ' + scrub(output).slice(-6000)) + for (const name of [...PRODUCT_PACKAGES, 'workdsh-bundle']) if (!ids.has(name)) throw new Error('Owned client missing at runtime: ' + name + '; owned graph=' + [...ids].filter(row => row.startsWith('workdsh-')).join(',') + '; ' + diagnostics()) const owned = [...ids].filter(name => name.startsWith('workdsh-')).sort() if (JSON.stringify(owned) !== JSON.stringify([...PRODUCT_PACKAGES, 'workdsh-bundle'].sort())) throw new Error('Unexpected default owned client inventory') for (const batch of graph.batches) { - const artifact = await fetch(new URL(batch.url, url), { signal: AbortSignal.timeout(20_000) }) + const address = new URL(batch.url, url) + if (address.origin !== new URL(url).origin) throw new Error('Client batch escaped the loopback runtime origin') + const artifact = await fetch(address, { redirect: 'error', signal: AbortSignal.timeout(20_000) }) if (!artifact.ok || !(await artifact.text()).includes('__ModuleLoader__')) throw new Error('Advertised client batch is unavailable') } if (existsSync(join(profile, 'node_modules'))) throw new Error('Runtime wrote a shared or copied dependency tree into the writable Profile') - if (/failed to import/iu.test(output)) throw new Error('Official Host reported an unresolved plugin import: ' + scrub(output).slice(-5000)) - console.log(JSON.stringify({ dsh: actual, independentWritableProfile: true, noProfileNodeModules: true, installationProvidedProductBundles: PRODUCT_PACKAGES, officialRoster: release.officialWeb.clients.length, loadedOfficialClients: [...ids].filter(name => name.startsWith('@deepseek-ai/dsh')).length, ownedClients: owned, clientBatches: graph.batches.length, modelCallsRequested: 0, guiLaunchRequested: false })) + if (/failed to import|startup.*failed|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND/iu.test(diagnostics())) throw new Error('Official Host reported a startup/import failure: ' + diagnostics()) + console.log(JSON.stringify({ dsh: actual, independentWritableProfile: true, noProfileNodeModules: true, installationProvidedProductBundles: PRODUCT_PACKAGES, initializedProductServices: receipt.services, officialRoster: release.officialWeb.clients.length, loadedOfficialClients: [...ids].filter(name => name.startsWith('@deepseek-ai/dsh')).length, ownedClients: owned, clientBatches: graph.batches.length, modelCallsRequested: 0, guiLaunchRequested: false })) } finally { if (child && child.exitCode === null) { child.kill('SIGTERM') let timeout await Promise.race([finished, new Promise(resolveWait => { timeout = setTimeout(() => { child.kill('SIGKILL'); resolveWait() }, 10_000) })]) clearTimeout(timeout) - await finished + } + if (child) { + // Failed startup can leave a descendant holding inherited stdio open, + // including after the direct Host process has already exited. + child.stdout.destroy(); child.stderr.destroy() + if (child.connected) child.disconnect() + let forcedTimeout + await Promise.race([finished, new Promise(resolveWait => { forcedTimeout = setTimeout(resolveWait, 3000) })]) + clearTimeout(forcedTimeout) } rmSync(home, { recursive: true, force: true }) } diff --git a/apps/desktop/scripts/verify-workdsh-carrier.ts b/apps/desktop/scripts/verify-workdsh-carrier.ts index 86122030a1..9040e8955a 100644 --- a/apps/desktop/scripts/verify-workdsh-carrier.ts +++ b/apps/desktop/scripts/verify-workdsh-carrier.ts @@ -153,6 +153,10 @@ export async function afterPack(context: PackContext): Promise { throw new Error(`Packaged WorkDSH plugin inventory check failed: ${String(inventory.error ?? inventory.stderr)}`) } process.stdout.write(inventory.stdout) + const hostCheck = fileURLToPath(new URL('./verify-installation-anchor.mjs', import.meta.url)) + const host = spawnSync(node, [hostCheck, runtime], { encoding: 'utf8', timeout: 150_000, maxBuffer: 1024 * 1024 }) + if (host.error || host.status !== 0) throw new Error('Packaged full Host startup check failed: ' + String(host.error ?? host.stderr + host.stdout)) + process.stdout.write(host.stdout) console.log(`Verified thin Electron carrier and ${names.length} root / ${instances} total Harness ${DSH_VERSION} package instances`) } diff --git a/apps/desktop/src/runtime-diagnostics.ts b/apps/desktop/src/runtime-diagnostics.ts index 3a0d41ae38..97da0b639d 100644 --- a/apps/desktop/src/runtime-diagnostics.ts +++ b/apps/desktop/src/runtime-diagnostics.ts @@ -40,7 +40,27 @@ export function createRuntimeOutputRedactor(write: (text: string) => void) { export function runtimeFailureDetail(ready: boolean, code: number | null, output: string, cleanupFailed = false): string { const cause = ready ? `官方 DSH 进程已退出 (${code})` : `官方 DSH 未能完成启动 (${code})`; - const diagnostic = redactRuntimeOutput(output).trim().slice(-4000); - return [cause, diagnostic ? `最近的运行输出:\n${diagnostic}` : '没有取得运行输出,请检查插件版本和 Profile 配置。', + const diagnostic = redactRuntimeOutput(output).trim(); + const reasons: string[] = []; + if (/EADDRINUSE/u.test(diagnostic)) { + const port = diagnostic.match(/(?:127\.0\.0\.1|localhost|\[::1\]):(\d+)/u)?.[1] + ?? diagnostic.match(/\bport:\s*(\d+)/u)?.[1]; + reasons.push(`本机服务端口${port ? ` ${port}` : ''}已被占用。请关闭此前的 WorkDSH 工作区后重试。`); + } + for (const match of diagnostic.matchAll(/Cannot find (?:module|package) ['"]([^'"\n]+)['"]/gu)) { + if (/^(?:@[\w.-]+\/)?[\w.-]+(?:\/[\w.-]+)*$/u.test(match[1]!)) reasons.push(`缺少运行依赖:${match[1]}。请使用包含完整依赖的安装包。`); + } + if (!reasons.length && /\b(?:EACCES|EPERM)\b/u.test(diagnostic)) reasons.push('无法访问运行所需的文件或目录,请检查运行目录的权限。'); + if (!reasons.length && diagnostic) { + const lines = diagnostic.split('\n').map(line => line.trim()).filter(line => line && !/^(?:at\s|file:\/\/|\[errors\]|[{}[\]])/u.test(line)); + const relevant = lines.filter(line => /\b(?:Error|failed|missing|ENOENT)\b/iu.test(line)); + reasons.push(...(relevant.length ? relevant : lines).slice(0, 3).map(line => line.slice(0, 200))); + } + return [cause, reasons.length ? [...new Set(reasons)].slice(0, 3).join('\n') : '没有取得运行输出,请检查插件版本和 Profile 配置。', ...(cleanupFailed ? ['另外,本机工具进程停止尚未确认,请退出后检查。'] : [])].join('\n\n'); } + +/** Preserve the bounded redacted trace in the local record, not in a giant alert. */ +export function runtimeFailureLog(ready: boolean, code: number | null, output: string, cleanupFailed = false): string { + return `${new Date().toISOString()}\n${runtimeFailureDetail(ready, code, output, cleanupFailed)}\n\n最近的运行输出:\n${redactRuntimeOutput(output).trim().slice(-16_384)}\n`; +} diff --git a/apps/desktop/src/runtime-port.ts b/apps/desktop/src/runtime-port.ts new file mode 100644 index 0000000000..cbf5e771c8 --- /dev/null +++ b/apps/desktop/src/runtime-port.ts @@ -0,0 +1,106 @@ +/** Home-owned loopback origin, configured through the official webserver row. */ +import { randomUUID } from 'node:crypto' +import { readFile, mkdir, rename, rm, writeFile } from 'node:fs/promises' +import { createServer } from 'node:net' +import { join } from 'node:path' +import * as yaml from 'js-yaml' + +const HOST = '127.0.0.1' +const STATE_FILE = '.workdsh-loopback.json' +const PATCH_FILE = '.workdsh-loopback.patch.yml' +// The carrier's lightweight declaration exposes load/dump only; retain a +// bundled module import for the schema API instead of an external require. +const codec = yaml as unknown as { + JSON_SCHEMA: { extend(type: unknown): unknown } + Type: new (tag: string, options: { kind: string, construct(value: unknown): unknown }) => unknown + load(text: string, options: { schema: unknown }): unknown + dump(value: unknown): string +} +const schema = codec.JSON_SCHEMA.extend(new codec.Type('tag:yaml.org,2002:js', { + kind: 'scalar', construct: value => ({ __jsExpr: value }), +})) +export type RuntimePort = { managed: false } | { managed: true, host: string, port: number, patch: string } + +async function readOptional(file: string): Promise { + try { return await readFile(file, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + throw error + } +} +function hasServerOverride(value: unknown): boolean { + if (Array.isArray(value)) return value.some(hasServerOverride) + if (!value || typeof value !== 'object') return false + const row = value as Record + // A user's row is authoritative, including expressions, TLS, compression, + // disabling or replacing the server. Never evaluate or rewrite that layer. + if (row.id === 'webserver') return true + return Object.values(row).some(hasServerOverride) +} +async function savedPort(file: string): Promise { + const text = await readOptional(file) + if (text === undefined) return undefined + let state: { version?: unknown, host?: unknown, port?: unknown } + try { state = JSON.parse(text) as typeof state } catch { throw new Error('工作区端口记录无效,请核对本机配置;未更改原有访问地址。') } + if (!state || state.version !== 1 || state.host !== HOST || !Number.isInteger(state.port) || Number(state.port) < 1024 || Number(state.port) > 65535) { + throw new Error('工作区端口记录无效,请核对本机配置;未更改原有访问地址。') + } + return Number(state.port) +} +/** Reserve briefly to check a bind; the official server remains the sole owner. */ +async function availablePort(port: number): Promise { + return new Promise((resolve, reject) => { + const probe = createServer() + probe.once('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') resolve(undefined) + else reject(error) + }) + probe.listen({ host: HOST, port, exclusive: true }, () => { + const address = probe.address() + const selected = address && typeof address === 'object' ? address.port : undefined + probe.close(error => error ? reject(error) : resolve(selected)) + }) + }) +} + +/** Existing Homes keep their origin; a conflicting owner is never stopped. */ +export async function prepareRuntimePort(home: string, userPatchFiles: string[], options: { preferredPort?: number, existingHome?: boolean } = {}): Promise { + const preferredPort = options.preferredPort ?? 3080 + for (const file of userPatchFiles) { + const text = await readOptional(file) + if (text !== undefined && hasServerOverride(codec.load(text, { schema }))) return { managed: false } + } + await mkdir(home, { recursive: true, mode: 0o700 }) + const state = join(home, STATE_FILE) + let port = await savedPort(state) + if (port !== undefined) { + if (await availablePort(port) === undefined) throw new Error(`此工作区原有地址 ${HOST}:${port} 正被占用。请先关闭占用该地址的应用后重试;为保留页面数据,本次未切换地址,也未停止其他进程。`) + } else { + port = await availablePort(preferredPort) + // Before Home-owned records existed, this Desktop used 3080. Preserve + // that origin for an existing Profile instead of hiding its localStorage. + if (port === undefined && options.existingHome) throw new Error(`已有工作区原地址 ${HOST}:${preferredPort} 正被占用。为保留页面数据,本次未切换地址,也未停止其他进程;请关闭占用该地址的应用后重试。`) + port ??= await availablePort(0) + if (port === undefined) throw new Error('无法为工作区分配本机端口,请稍后重试。') + try { + await writeFile(state, JSON.stringify({ version: 1, host: HOST, port }) + '\n', { flag: 'wx', mode: 0o600 }) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + // Another launch created this Home's origin while we probed. Reuse that + // record; never replace it with a second origin chosen concurrently. + port = await savedPort(state) + if (port === undefined || await availablePort(port) === undefined) throw new Error('此工作区正在由另一个启动操作使用,请关闭重复窗口后重试。') + } + } + const patch = join(home, PATCH_FILE), temporary = `${patch}.${randomUUID()}` + try { + await writeFile(temporary, codec.dump([{ id: 'webserver', config: { host: HOST, port } }]), { flag: 'wx', mode: 0o600 }) + await rename(temporary, patch) + } finally { await rm(temporary, { force: true }) } + return { managed: true, host: HOST, port, patch } +} + +/** Probe/launch is a narrow race; report its actual bind error without scanning. */ +export function runtimePortConflict(selection: RuntimePort, output: string): string | undefined { + if (!selection.managed || !/\bEADDRINUSE\b|address already in use/iu.test(output)) return undefined + return `工作区端口 ${selection.host}:${selection.port} 在启动时被占用,请关闭占用该地址的应用后重试。已保留原地址和数据,未停止其他进程。` +} diff --git a/apps/desktop/src/workdsh-main.ts b/apps/desktop/src/workdsh-main.ts index 136e082dc5..0d027229eb 100644 --- a/apps/desktop/src/workdsh-main.ts +++ b/apps/desktop/src/workdsh-main.ts @@ -18,8 +18,9 @@ import { parseDeploymentConfig } from './deployment-config.ts' import { EnterpriseLogin, startEnterpriseAuthority, type Authority } from './enterprise-auth.ts' import { desktopEnterprisePatch, enterpriseEnvironment, enterpriseSpace, materializeRuntimeProfile, markProfileUpdated, officialLauncher, officialHostLauncher } from './local-runtime.ts' import { manageCommand } from './command-management.ts' -import { createRuntimeOutputRedactor, redactRuntimeOutput, runtimeFailureDetail } from './runtime-diagnostics.ts' +import { createRuntimeOutputRedactor, redactRuntimeOutput, runtimeFailureDetail, runtimeFailureLog } from './runtime-diagnostics.ts' import { cleanOwnedProcessTree, stopOwnedProcess } from './owned-process.ts' +import { prepareRuntimePort, runtimePortConflict } from './runtime-port.ts' const PROFILE_NAME = 'workdsh' const READY_PATTERN = /dsh web:\s+(http:\/\/127\.0\.0\.1:\d+\/?\?token=[^\s]+)/u @@ -206,7 +207,9 @@ async function officialCommand(home: string, args: string[], timeoutMs = 240_000 }) } -async function prepareProfile(home: string): Promise { +async function prepareProfile(home: string): Promise<{ profile: string, existingHome: boolean }> { + // Capture this before materialization creates the new Home's Profile files. + const existingHome = [join(home, 'profiles', PROFILE_NAME, 'package.json'), join(home, 'profiles', PROFILE_NAME, 'cordis.yml'), join(home, 'cordis.yml'), join(home, '.workdsh-host.mjs')].some(existsSync) const source = bundledProfileDirectory() const result = materializeRuntimeProfile(source, home) if (result.changed) { @@ -217,7 +220,7 @@ async function prepareProfile(home: string): Promise { if (entry && !entry.isDestroyed()) await entry.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(connectionLoadingHtml(enterprise ? '正在启动企业工作区' : '正在启动个人工作区'))) } officialLauncher(source, home, bundledNodeExecutable(), join(bundledPrimaryRuntime(), 'dependencies', 'pnpm', 'bin', 'pnpm.cjs')) - return result.profile + return { profile: result.profile, existingHome } } function rememberCommandSpace(home?: string): void { @@ -226,13 +229,15 @@ function rememberCommandSpace(home?: string): void { writeFileSync(join(app.getPath('userData'), 'command-context.json'), JSON.stringify({ personalHome: runtimeHome(), active }), { mode: 0o600 }) } -function startRuntime(home: string, connection: DesktopConnection, patches: string[] = []): void { +async function startRuntime(home: string, connection: DesktopConnection, patches: string[] = [], existingHome = false): Promise { rememberCommandSpace(home) if (quitRequested) throw new Error('启动已取消') const executable = bundledNodeExecutable() if (!existsSync(executable)) throw new Error(`Bundled Node.js runtime is missing: ${executable}`) const basePatch = join(bundledProfileDirectory(), 'cordis.patch.yml') - const layers = [...(existsSync(basePatch) ? [basePatch] : []), ...patches] + const port = await prepareRuntimePort(home, [join(home, 'profiles', PROFILE_NAME, 'cordis.yml'), join(home, 'profiles', PROFILE_NAME, 'cordis.patch.yml'), join(home, 'cordis.yml'), join(home, 'cordis.patch.yml'), ...patches], { existingHome }) + if (quitRequested) throw new Error('启动已取消') + const layers = [...(existsSync(basePatch) ? [basePatch] : []), ...patches, ...(port.managed ? [port.patch] : [])] const launcher = officialHostLauncher(bundledProfileDirectory(), home, executable, join(bundledPrimaryRuntime(), 'dependencies', 'pnpm', 'bin', 'pnpm.cjs'), layers) const child = spawn(executable, [launcher, '--no-open'], { env: runtimeEnvironment(home), @@ -265,12 +270,12 @@ function startRuntime(home: string, connection: DesktopConnection, patches: stri } } const failureDetail = (code: number | null, cleanupFailed = false): string => { - const detail = runtimeFailureDetail(ready, code, diagnosticOutput, cleanupFailed) + const detail = [!ready ? runtimePortConflict(port, diagnosticOutput) : undefined, runtimeFailureDetail(ready, code, diagnosticOutput, cleanupFailed)].filter(Boolean).join('\n\n') try { const directory = join(app.getPath('userData'), 'logs') mkdirSync(directory, { recursive: true }) const path = join(directory, 'runtime-failure.log') - writeFileSync(path, `${new Date().toISOString()}\n${detail}\n`, { mode: 0o600 }) + writeFileSync(path, runtimeFailureLog(ready, code, diagnosticOutput, cleanupFailed), { mode: 0o600 }) return `${detail}\n\n错误记录:${path}` } catch { return detail } } @@ -380,9 +385,9 @@ function connectEntryNavigation(entry: BrowserWindow): void { if (enterprise) await leaveWorkspace() const home = runtimeHome() await entry.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(connectionLoadingHtml('正在启动个人工作区'))) - await prepareProfile(home) + const prepared = await prepareProfile(home) entrySurface = false - startRuntime(home, { mode: 'personal' }) + await startRuntime(home, { mode: 'personal' }, [], prepared.existingHome) } else if (command.hostname === 'entry') { await leaveWorkspace(); showConnectionEntry() } @@ -399,7 +404,7 @@ function enterprisePluginAvailable():boolean { catch{return false} } -async function activateEnterprise(): Promise { +async function activateEnterprise(existingHome: boolean): Promise { const session = enterprise if (!session) throw new Error('请重新登录企业账号') await session.login.verify() @@ -424,7 +429,7 @@ async function activateEnterprise(): Promise { const connection: DesktopConnection = { mode: 'enterprise', portalOrigin: session.login.backendUrl, organizationId: session.login.actor.organizationId, principalId: session.login.actor.id } if (window) await window.loadURL('data:text/html;charset=utf-8,' + encodeURIComponent(connectionLoadingHtml('正在启动企业工作区'))) entrySurface = false - startRuntime(session.home, connection, [patch.patch]) + await startRuntime(session.home, connection, [patch.patch], existingHome) verifyTimer = setInterval(() => { if (verifying || switching) return verifying = true @@ -450,8 +455,8 @@ function installLoginHandlers(): void { admittedLogin = login const space = enterpriseSpace(app.getPath('userData'), login.backendUrl, login.actor) enterprise = { login, ...space } - await prepareProfile(space.home) - await activateEnterprise() + const prepared = await prepareProfile(space.home) + await activateEnterprise(prepared.existingHome) return {} } catch (error) { const message = error instanceof Error ? error.message : '企业登录失败' diff --git a/apps/desktop/tests/packaged-host-smoke.spec.ts b/apps/desktop/tests/packaged-host-smoke.spec.ts new file mode 100644 index 0000000000..f0cefa9691 --- /dev/null +++ b/apps/desktop/tests/packaged-host-smoke.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { spawn } from 'node:child_process' +import { inspectStartedHost, hostSmokeEnvironment, waitForPackagedHostReady } from '../scripts/packaged-host-smoke.mjs' + +describe('packaged full Host startup', () => { + it('requires initialized selected products while allowing optional lazy official plugins', () => { + const profile = { startedBundles: ['workdsh-plugin-workbenches'], installAnchor: '/actual/resources/profile-installation.json' } + const values = new Map([['profileContext', profile], ['workdshWorkbenchRegistration', {}], ['storageDomain', {}]]) + const mandatory = { name: 'workdsh-workbenches', fibers: [{ state: 2 }] } + const ctx = { fiber: { state: 2 }, get: (key: string) => values.get(key), registry: new Map([['owned', mandatory], ['optional-model', { name: 'optional-model', fibers: [{ state: 0 }] }]]) } + expect(inspectStartedHost(ctx, { 'workdsh-plugin-workbenches': 'workdshWorkbenchRegistration' }, ['storageDomain']).services).toEqual(['workdshWorkbenchRegistration']) + mandatory.fibers[0]!.state = 0 + expect(() => inspectStartedHost(ctx, { 'workdsh-plugin-workbenches': 'workdshWorkbenchRegistration' }, ['storageDomain'])).toThrow('initialization is incomplete') + mandatory.fibers[0]!.state = 2 + values.delete('workdshWorkbenchRegistration') + expect(() => inspectStartedHost(ctx, { 'workdsh-plugin-workbenches': 'workdshWorkbenchRegistration' }, ['storageDomain'])).toThrow('service is unavailable') + }) + + it('does not inherit credentials, real Home or user DSH state', () => { + const env = hostSmokeEnvironment('/isolated/test', '/actual/primary', '/actual/node/bin/node', { PATH: '/usr/bin', HOME: '/real/user', DSH_HOME: '/real/state', API_KEY: 'must-not-inherit', TOKEN: 'must-not-inherit', NODE_OPTIONS: '--require=private-file' }) + expect(env.HOME).toBe('/isolated/test') + expect(env.DSH_HOME).toBe('/isolated/test') + expect(env.DSH_BUNDLED_PRIMARY_RUNTIME).toBe('/actual/primary') + expect(env.API_KEY).toBeUndefined() + expect(env.TOKEN).toBeUndefined() + expect(env.NODE_OPTIONS).toBeUndefined() + }) + + it('does not accept an HTTP listening message when a required dynamic import fails', async () => { + const child = spawn(process.execPath, ['-e', `console.log('http://127.0.0.1:12345/?token=private-process-token');console.error("Cannot find package '@deepseek-ai/dsh-atomic-write'");process.exitCode=1;`], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }) + const closed = new Promise(resolve => child.once('close', resolve)) + await expect(waitForPackagedHostReady(child, { timeoutMs: 2000 }).ready).rejects.toThrow('@deepseek-ai/dsh-atomic-write') + await closed + expect(child.exitCode).toBe(1) + }) + + it('waits for both official ready and product service receipt', async () => { + const child = spawn(process.execPath, ['-e', `process.send({kind:'workdsh-packaged-host-ready',products:['workdsh-plugin-workbenches'],services:['workdshWorkbenchRegistration'],installAnchor:'/actual/anchor'});setTimeout(()=>console.log('http://127.0.0.1:12345/?token=private-process-token'),20);setTimeout(()=>{},3000);`], { stdio: ['ignore', 'pipe', 'pipe', 'ipc'] }) + try { + const startup = waitForPackagedHostReady(child, { timeoutMs: 2000 }) + const result = await startup.ready + expect(result.receipt.services).toEqual(['workdshWorkbenchRegistration']) + expect(result.diagnostics()).toContain('?token=[redacted]') + expect(result.diagnostics()).not.toContain('private-process-token') + } finally { + child.kill('SIGTERM') + await new Promise(resolve => child.once('close', resolve)) + } + }) +}) diff --git a/apps/desktop/tests/runtime-diagnostics.spec.ts b/apps/desktop/tests/runtime-diagnostics.spec.ts index e35e505daf..5a46c926a6 100644 --- a/apps/desktop/tests/runtime-diagnostics.spec.ts +++ b/apps/desktop/tests/runtime-diagnostics.spec.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { createRuntimeOutputRedactor, redactRuntimeOutput, runtimeFailureDetail } from '../src/runtime-diagnostics.ts' +import { createRuntimeOutputRedactor, redactRuntimeOutput, runtimeFailureDetail, runtimeFailureLog } from '../src/runtime-diagnostics.ts' it('keeps startup cause when descendant cleanup also fails', () => { const detail = runtimeFailureDetail(false, 1, 'stderr: missing plugin entry', true) @@ -31,5 +31,18 @@ it('redacts credentials and bounds GUI diagnostics', () => { const raw = 'http://localhost/?token=secret&api_key=hidden Bearer private sk-testsecret {"bootstrapToken":"sensitive"}' const safe = redactRuntimeOutput(raw) for (const value of ['secret', 'hidden', 'private', 'sk-testsecret', 'sensitive']) expect(safe).not.toContain(value) - expect(runtimeFailureDetail(false, 1, 'x'.repeat(20000)).length).toBeLessThan(4200) + expect(runtimeFailureDetail(false, 1, 'x'.repeat(20000)).length).toBeLessThan(700) +}) + +it('summarizes simultaneous startup causes while retaining the redacted trace in the log', () => { + const raw = `Error: listen EADDRINUSE: address already in use 127.0.0.1:3080\n${' at file:///long/runtime/stack.js:10:1\n'.repeat(150)}Error: Cannot find module '@deepseek-ai/dsh-atomic-write'\nBearer sensitive-test-value`; + const detail = runtimeFailureDetail(false, 1, raw); + expect(detail).toContain('3080'); + expect(detail).toContain('@deepseek-ai/dsh-atomic-write'); + expect(detail).not.toContain('stack.js'); + expect(detail.length).toBeLessThan(400); + const log = runtimeFailureLog(false, 1, raw); + expect(log).toContain('stack.js'); + expect(log).not.toContain('sensitive-test-value'); + expect(log).toContain('[redacted]'); }) diff --git a/apps/desktop/tests/runtime-port.spec.ts b/apps/desktop/tests/runtime-port.spec.ts new file mode 100644 index 0000000000..da86d64d4b --- /dev/null +++ b/apps/desktop/tests/runtime-port.spec.ts @@ -0,0 +1,98 @@ +import { createServer, type Server } from 'node:http' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it } from 'vitest' +import { load } from 'js-yaml' +import { prepareRuntimePort, runtimePortConflict } from '../src/runtime-port.ts' + +const roots: string[] = [], servers: Server[] = [] +afterEach(async () => { + await Promise.all(servers.splice(0).filter(server => server.listening).map(server => new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())))) + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) +async function home(): Promise { + const root = await mkdtemp(join(tmpdir(), 'workdsh-port-test-')); roots.push(root); return root +} +async function occupied(port = 0): Promise { + const server = createServer((_request, response) => response.end('unrelated-host-retained')); servers.push(server) + await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve) }) + const address = server.address() + if (!address || typeof address !== 'object') throw new Error('Missing listener') + return address.port +} + +it('coexists with an existing Host, persists a new origin and reuses it on reopening', async () => { + const directory = await home(), existing = await occupied() + const first = await prepareRuntimePort(directory, [], { preferredPort: existing }) + expect(first.managed).toBe(true) + if (!first.managed) throw new Error('Expected managed port') + expect(first.port).not.toBe(existing) + expect(await (await fetch(`http://127.0.0.1:${existing}`)).text()).toBe('unrelated-host-retained') + expect(JSON.parse(await readFile(join(directory, '.workdsh-loopback.json'), 'utf8'))).toEqual({ version: 1, host: '127.0.0.1', port: first.port }) + expect(load(await readFile(first.patch, 'utf8'))).toEqual([{ id: 'webserver', config: { host: '127.0.0.1', port: first.port } }]) + // A now-free preferred port cannot move an existing Home's browser origin. + await new Promise((resolve, reject) => servers[0]!.close(error => error ? reject(error) : resolve())) + expect(await prepareRuntimePort(directory, [], { preferredPort: existing })).toEqual(first) +}) + +it('uses an available preferred port, but refuses to move a saved origin occupied by another process', async () => { + const directory = await home(), probe = await occupied() + await new Promise((resolve, reject) => servers[0]!.close(error => error ? reject(error) : resolve())) + const selected = await prepareRuntimePort(directory, [], { preferredPort: probe }) + expect(selected.managed && selected.port).toBe(probe) + const state = await readFile(join(directory, '.workdsh-loopback.json'), 'utf8') + await occupied(probe) + await expect(prepareRuntimePort(directory, [], { preferredPort: 0 })).rejects.toThrow('未切换地址') + expect(await readFile(join(directory, '.workdsh-loopback.json'), 'utf8')).toBe(state) + expect(await (await fetch(`http://127.0.0.1:${probe}`)).text()).toBe('unrelated-host-retained') +}) + +it('gives independent Homes different available ports while both official origins are in use', async () => { + const first = await prepareRuntimePort(await home(), [], { preferredPort: 0 }) + if (!first.managed) throw new Error('Expected managed port') + await occupied(first.port) + const second = await prepareRuntimePort(await home(), [], { preferredPort: first.port }) + expect(second.managed && second.port).not.toBe(first.port) +}) + +it('leaves explicit profile/Home/launcher webserver configuration and expressions untouched', async () => { + for (const filename of ['profiles/workdsh/cordis.yml', 'profiles/workdsh/cordis.patch.yml', 'cordis.yml', 'cordis.patch.yml', 'explicit.patch.yml']) { + const directory = await home(), file = join(directory, filename) + await mkdir(join(directory, 'profiles/workdsh'), { recursive: true }) + const user = '# explicit user config\n- id: webserver\n config:\n host: 127.0.0.1\n port: !!js "throw new Error(\'must-not-evaluate\')"\n' + await writeFile(file, user) + expect(await prepareRuntimePort(directory, [file], { preferredPort: 0 })).toEqual({ managed: false }) + expect(await readFile(file, 'utf8')).toBe(user) + await expect(readFile(join(directory, '.workdsh-loopback.json'))).rejects.toHaveProperty('code', 'ENOENT') + } +}) + +it('rejects invalid saved records without resetting the Home origin', async () => { + const directory = await home(), file = join(directory, '.workdsh-loopback.json') + for (const state of ['not-json', JSON.stringify({ version: 1, host: '0.0.0.0', port: 3080 }), JSON.stringify({ version: 1, host: '127.0.0.1', port: 0 })]) { + await writeFile(file, state) + await expect(prepareRuntimePort(directory, [], { preferredPort: 0 })).rejects.toThrow('未更改原有访问地址') + expect(await readFile(file, 'utf8')).toBe(state) + } +}) + +it('explains an actual EADDRINUSE launch race without treating unrelated failures as a port conflict', () => { + const selection = { managed: true as const, host: '127.0.0.1', port: 3081, patch: '/owned/overlay.yml' } + expect(runtimePortConflict(selection, 'Error: listen EADDRINUSE: address already in use 127.0.0.1:3081')).toContain('3081') + expect(runtimePortConflict(selection, 'Error: plugin initialization failed')).toBeUndefined() + expect(runtimePortConflict({ managed: false }, 'EADDRINUSE')).toBeUndefined() +}) + + +it('keeps a legacy Home on its original port and refuses a busy origin before creating a new record', async () => { + const directory = await home(), originalPort = await occupied() + // The caller captured existingHome before prepareProfile created any files. + await expect(prepareRuntimePort(directory, [], { preferredPort: originalPort, existingHome: true })).rejects.toThrow('未切换地址') + await expect(readFile(join(directory, '.workdsh-loopback.json'))).rejects.toHaveProperty('code', 'ENOENT') + expect(await (await fetch(`http://127.0.0.1:${originalPort}`)).text()).toBe('unrelated-host-retained') + await new Promise((resolve, reject) => servers[0]!.close(error => error ? reject(error) : resolve())) + const migrated = await prepareRuntimePort(directory, [], { preferredPort: originalPort, existingHome: true }) + expect(migrated.managed && migrated.port).toBe(originalPort) + expect(JSON.parse(await readFile(join(directory, '.workdsh-loopback.json'), 'utf8')).port).toBe(originalPort) +}) diff --git a/apps/web/pnpm-lock.yaml b/apps/web/pnpm-lock.yaml index 9505d90184..8a827182a6 100644 --- a/apps/web/pnpm-lock.yaml +++ b/apps/web/pnpm-lock.yaml @@ -1440,6 +1440,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: 0.2.1-rc.1 version: 0.2.1-rc.1(@deepseek-ai/cordis-plugin-group@1.0.5(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/cordis-plugin-include@1.0.10)(@deepseek-ai/cordis-plugin-loader@1.0.6)(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-home-paths@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-launch-environment@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5)) + '@deepseek-ai/dsh-atomic-write': + specifier: 0.2.1-rc.1 + version: 0.2.1-rc.1(@deepseek-ai/cordis@4.0.5) '@deepseek-ai/dsh-client-connection': specifier: 0.2.1-rc.1 version: 0.2.1-rc.1(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5)) @@ -1467,6 +1470,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: 0.2.1-rc.1 version: 0.2.1-rc.1(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-brand@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-llm@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-sandbox@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-llm@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5))(@deepseek-ai/dsh-session@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-scope@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5)))) + '@deepseek-ai/dsh-plugin-manager': + specifier: 0.2.1-rc.1 + version: 0.2.1-rc.1(b1054fea997ea31b14a908962f32956b) '@deepseek-ai/dsh-storage-domain': specifier: 0.2.1-rc.1 version: 0.2.1-rc.1(@deepseek-ai/cordis@4.0.5)(@deepseek-ai/dsh-storage@0.2.1-rc.1(@deepseek-ai/cordis@4.0.5)) diff --git a/packages/plugins/workbenches/package.json b/packages/plugins/workbenches/package.json index 770d44dd22..cc170387c5 100644 --- a/packages/plugins/workbenches/package.json +++ b/packages/plugins/workbenches/package.json @@ -52,6 +52,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "4.0.5", "@deepseek-ai/dsh-app-boot": "0.2.1-rc.1", + "@deepseek-ai/dsh-atomic-write": "0.2.1-rc.1", "@deepseek-ai/dsh-client-connection": "0.2.1-rc.1", "@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.1", "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.1", @@ -61,6 +62,7 @@ "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.1", "@deepseek-ai/dsh-client-ui-workspace": "0.2.1-rc.1", "@deepseek-ai/dsh-fs": "0.2.1-rc.1", + "@deepseek-ai/dsh-plugin-manager": "0.2.1-rc.1", "@deepseek-ai/dsh-storage-domain": "0.2.1-rc.1", "@deepseek-ai/dsh-tools": "0.2.1-rc.1" }, @@ -69,6 +71,7 @@ "@deepseek-ai/dsh-api-session-controller": "0.2.1-rc.1", "@deepseek-ai/dsh-api-workspace-controller": "0.2.1-rc.1", "@deepseek-ai/dsh-app-boot": "0.2.1-rc.1", + "@deepseek-ai/dsh-atomic-write": "0.2.1-rc.1", "@deepseek-ai/dsh-client-connection": "0.2.1-rc.1", "@deepseek-ai/dsh-client-ui-conversation": "0.2.1-rc.1", "@deepseek-ai/dsh-client-ui-layout": "0.2.1-rc.1", @@ -78,6 +81,7 @@ "@deepseek-ai/dsh-client-ui-slots": "0.2.1-rc.1", "@deepseek-ai/dsh-client-ui-workspace": "0.2.1-rc.1", "@deepseek-ai/dsh-fs": "0.2.1-rc.1", + "@deepseek-ai/dsh-plugin-manager": "0.2.1-rc.1", "@deepseek-ai/dsh-storage-domain": "0.2.1-rc.1", "@deepseek-ai/dsh-tools": "0.2.1-rc.1", "@types/node": "22.19.0", diff --git a/packages/plugins/workbenches/tests/runtime-dependency-closure.test.mjs b/packages/plugins/workbenches/tests/runtime-dependency-closure.test.mjs new file mode 100644 index 0000000000..fa72a73d0c --- /dev/null +++ b/packages/plugins/workbenches/tests/runtime-dependency-closure.test.mjs @@ -0,0 +1,105 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire, isBuiltin} from 'node:module'; +import {mkdtemp, mkdir, readFile, readdir, realpath, rm, symlink, writeFile} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {dirname, isAbsolute, join} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const sdkDirectory = fileURLToPath(new URL('..', import.meta.url)); +const repository = fileURLToPath(new URL('../../../../', import.meta.url)); +const sdk = JSON.parse(await readFile(join(sdkDirectory, 'package.json'), 'utf8')); +const sdkRequire = createRequire(join(sdkDirectory, 'package.json')); +const webRequire = createRequire(join(repository, 'apps/web/package.json')); +const officialRequire = createRequire(await realpath(webRequire.resolve('@deepseek-ai/dsh/package.json'))); +const ts = webRequire('typescript'); +const {build} = webRequire('esbuild'); +const required = {...sdk.dependencies, ...Object.fromEntries(Object.entries(sdk.peerDependencies ?? {}) + .filter(([name]) => sdk.peerDependenciesMeta?.[name]?.optional !== true))}; +const packageName = specifier => specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0]; + +// Parse actual runtime sources so another fixed dynamic dependency cannot hide +// behind the larger development tree or a devDependency-only declaration. +async function fixedRuntimeSpecifiers(directory, collected = new Set()) { + for (const entry of await readdir(directory, {withFileTypes: true})) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { await fixedRuntimeSpecifiers(path, collected); continue; } + if (!/\.(?:tsx?|mjs)$/.test(entry.name)) continue; + const source = ts.createSourceFile(path, await readFile(path, 'utf8'), ts.ScriptTarget.Latest, true); + function visit(node) { + if (ts.isCallExpression(node) && node.arguments.length === 1 && ts.isStringLiteralLike(node.arguments[0])) { + const callee = node.expression; + const resolves = ts.isPropertyAccessExpression(callee) && callee.name.text === 'resolve'; + const requires = ts.isIdentifier(callee) && callee.text === 'require'; + const createdRequire = ts.isCallExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === 'createRequire'; + if (resolves || requires || createdRequire) { + const specifier = node.arguments[0].text; + if (!isBuiltin(specifier) && !specifier.startsWith('.') && !isAbsolute(specifier)) collected.add(specifier); + } + } + ts.forEachChild(node, visit); + } + visit(source); + } + return collected; +} + +const fixed = await fixedRuntimeSpecifiers(join(sdkDirectory, 'src')); +await fixedRuntimeSpecifiers(join(sdkDirectory, 'scripts'), fixed); +const installerOutput = await build({entryPoints: [join(sdkDirectory, 'src/runtime/profile-installer.ts')], bundle: true, platform: 'node', format: 'esm', write: false}); +const {createProfileWorkbenchInstaller} = await import(`data:text/javascript;base64,${Buffer.from(installerOutput.outputFiles[0].text).toString('base64')}`); + +async function isolatedProfile(directory, omitted = []) { + await mkdir(directory, {recursive: true}); + const anchor = join(directory, 'package.json'); + await writeFile(anchor, JSON.stringify({private: true, dependencies: required})); + // Only the declared production closure is visible at this root. The physical + // packages are real published packages; their own transitive dependencies are + // preserved, but cannot make an undeclared name resolvable from this anchor. + for (const name of [sdk.name, ...Object.keys(required)].filter(name => !omitted.includes(name))) { + let target = sdkDirectory; + if (name !== sdk.name) { + let manifest; + try { manifest = sdkRequire.resolve(`${name}/package.json`); } + catch { manifest = officialRequire.resolve(`${name}/package.json`); } + target = dirname(await realpath(manifest)); + const installed = JSON.parse(await readFile(join(target, 'package.json'), 'utf8')); + assert.equal(installed.version, required[name], `fixture must use the exact declared ${name}`); + } + const link = join(directory, 'node_modules', name); + await mkdir(dirname(link), {recursive: true}); + await symlink(target, link, process.platform === 'win32' ? 'junction' : 'dir'); + } + return {name: 'closure-test', dir: directory, home: directory, cwd: directory, installAnchor: anchor}; +} + +test('fixed runtime lookups are mandatory production dependencies, including public subpaths', () => { + assert.ok(fixed.has('@deepseek-ai/dsh-atomic-write')); + assert.ok(fixed.has('@deepseek-ai/dsh-plugin-manager/operations')); + for (const specifier of fixed) { + const name = packageName(specifier); + if (name !== sdk.name) assert.ok(Object.hasOwn(required, name), `undeclared production lookup: ${specifier}`); + } +}); + +test('installer loads real public APIs from an isolated declared Profile root', async () => { + const directory = await mkdtemp(join(tmpdir(), 'workdsh-profile-closure-')); + try { + const profile = await isolatedProfile(directory); + const require = createRequire(profile.installAnchor); + for (const specifier of fixed) assert.ok(require.resolve(specifier), `unresolvable Profile lookup: ${specifier}`); + const installer = await createProfileWorkbenchInstaller({get: name => name === 'profileContext' ? profile : undefined}, () => assert.fail('loading must not authorize an installation')); + assert.equal(typeof installer, 'function'); + } finally { await rm(directory, {recursive: true, force: true}); } +}); + +test('Profile closure check detects each missing dynamically loaded installer package', async () => { + const directory = await mkdtemp(join(tmpdir(), 'workdsh-profile-missing-')); + try { + for (const name of ['@deepseek-ai/dsh-atomic-write', '@deepseek-ai/dsh-plugin-manager']) { + const profile = await isolatedProfile(join(directory, name.split('/').at(-1)), [name]); + await assert.rejects(createProfileWorkbenchInstaller({get: () => profile}, () => {}), error => + error.code === 'MODULE_NOT_FOUND' && error.message.includes(name)); + } + } finally { await rm(directory, {recursive: true, force: true}); } +}); From c61d351838548ec345b3c4e1635c498dbe21446d Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Sun, 18 Oct 2026 12:50:05 +0800 Subject: [PATCH 3/7] Preserve packaged SDK exports and diagnose profile upgrade failures --- apps/desktop/package.json | 3 - .../scripts/verify-profile-release.d.mts | 1 + .../desktop/scripts/verify-workdsh-carrier.ts | 14 ++++- apps/desktop/src/local-runtime.ts | 6 ++ apps/desktop/src/official-command.ts | 59 ++++++++++++++++++ apps/desktop/src/plugin-archive-cache.ts | 15 ++++- apps/desktop/src/runtime-diagnostics.ts | 12 ++++ apps/desktop/src/workdsh-main.ts | 19 ++---- .../tests/connection-lifecycle.spec.ts | 2 + apps/desktop/tests/local-runtime.spec.ts | 45 +++++++++++++- apps/desktop/tests/official-command.spec.ts | 50 +++++++++++++++ .../tests/plugin-archive-cache.spec.ts | 29 +++++++-- .../desktop/tests/runtime-diagnostics.spec.ts | 10 ++- .../tests/verify-workdsh-carrier.spec.ts | 61 ++++++++++++++++++- 14 files changed, 295 insertions(+), 31 deletions(-) create mode 100644 apps/desktop/src/official-command.ts create mode 100644 apps/desktop/tests/official-command.spec.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1240862457..86e9c43b58 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -192,9 +192,6 @@ "node/**", "primary-runtime/**", "office-skills/**", - "!profiles/workdsh/node_modules/**/*.d.ts", - "!profiles/workdsh/node_modules/**/*.d.mts", - "!profiles/workdsh/node_modules/**/*.d.cts", "!profiles/workdsh/node_modules/**/*.pdb" ] } diff --git a/apps/desktop/scripts/verify-profile-release.d.mts b/apps/desktop/scripts/verify-profile-release.d.mts index da579f18d2..3e888e029c 100644 --- a/apps/desktop/scripts/verify-profile-release.d.mts +++ b/apps/desktop/scripts/verify-profile-release.d.mts @@ -22,3 +22,4 @@ export function verifyPackageDshReferences(manifest: PackageReferences, expected export function verifyReleaseArchives(manifest: { packages?: ReadonlyArray<{ name: string; filename: string; sha256: string }> }, directory: string): void export function verifyOfficialWebPackages(manifest: unknown, profile: string, config?: string): void export function verifyInstalledDshVersions(profile: string, expectedDshVersion: string): number +export function verifyBuiltPackageExports(manifest: { name?: string; exports?: unknown }, directory: string): void diff --git a/apps/desktop/scripts/verify-workdsh-carrier.ts b/apps/desktop/scripts/verify-workdsh-carrier.ts index 9040e8955a..cf42d5f518 100644 --- a/apps/desktop/scripts/verify-workdsh-carrier.ts +++ b/apps/desktop/scripts/verify-workdsh-carrier.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' import { extractFile, listPackage } from '@electron/asar' import { DSH_VERSION, OFFICIAL_PNPM_VERSION } from './runtime-version.mjs' import { ENTERPRISE_PACKAGES, PRODUCT_PACKAGES, RELEASE_PACKAGES } from './workdsh-package-boundary.mjs' -import { verifyDefaultComposition, verifyDefaultProfile, verifyInstalledDshVersions, verifyOfficialWebPackages, verifyProfileRelease, verifyReleaseArchives } from './verify-profile-release.mjs' +import { verifyBuiltPackageExports, verifyDefaultComposition, verifyDefaultProfile, verifyInstalledDshVersions, verifyOfficialWebPackages, verifyProfileRelease, verifyReleaseArchives } from './verify-profile-release.mjs' import { parseDeploymentConfig } from '../src/deployment-config.ts' import { verifyNativeWorkbenchFrame } from '../../../scripts/native-workbench-frame-patch.mjs' @@ -20,6 +20,17 @@ export function normalizeAsarEntry(entry: string): string { return entry.replaceAll('\\', '/').replace(/^\//u, '') } +/** Read final copied package entries, including public SDK declarations. A + * valid source archive cannot prove that extraResources retained its exports. */ +export function verifyPackagedOwnedExports(profile: string, names: readonly string[] = RELEASE_PACKAGES): void { + for (const name of names) { + const directory = join(profile, 'node_modules', name) + const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as { name?: string; exports?: unknown } + if (manifest.name !== name) throw new Error('Packaged export inventory names another package: ' + name) + verifyBuiltPackageExports(manifest, directory) + } +} + export async function afterPack(context: PackContext): Promise { if (context.electronPlatformName === 'darwin') { const plist = join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Info.plist') @@ -69,6 +80,7 @@ export async function afterPack(context: PackContext): Promise { packages: Array<{ name: string; version: string; filename: string; sha256: string }> } verifyProfileRelease(release, DSH_VERSION, RELEASE_PACKAGES) + verifyPackagedOwnedExports(profile, release.packages.map(item => item.name)) verifyReleaseArchives(release, cache) verifyOfficialWebPackages(release, profile) const profileManifest = JSON.parse(readFileSync(join(profile, 'package.json'), 'utf8')) as { diff --git a/apps/desktop/src/local-runtime.ts b/apps/desktop/src/local-runtime.ts index ded61771c9..fc3b94e016 100644 --- a/apps/desktop/src/local-runtime.ts +++ b/apps/desktop/src/local-runtime.ts @@ -73,6 +73,12 @@ export function materializeRuntimeProfile(source: string, home: string): { profi } else if (previous?.digest !== digest) { if (!previous) throw new Error('已有 Profile 缺少 Desktop 版本记录;请在官方插件管理中核对后使用独立空间') const current = json(manifest) + // Only the unchanged recorded default remains product-owned. A user may + // pin another manager/version or remove the declaration altogether. + if (current.packageManager === previous.base.packageManager) { + if (base.packageManager === undefined) delete current.packageManager + else current.packageManager = base.packageManager + } for (const section of ['dependencies', 'optionalDependencies'] as const) { const owned = previous.base[section] ?? {} const next = base[section] ?? {} diff --git a/apps/desktop/src/official-command.ts b/apps/desktop/src/official-command.ts new file mode 100644 index 0000000000..217bbcddaf --- /dev/null +++ b/apps/desktop/src/official-command.ts @@ -0,0 +1,59 @@ +import { spawn, type ChildProcess } from 'node:child_process' +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { cleanOwnedProcessTree, stopOwnedProcess } from './owned-process.ts' +import { createRuntimeOutputRedactor, officialCommandFailureDetail } from './runtime-diagnostics.ts' + +/** Preserve the actual CLI failure before returning control to the entry screen. */ +export async function runOfficialCommand(options: { + executable: string, launcher: string, args: string[], home: string, env: NodeJS.ProcessEnv, + logDirectory: string, timeoutMs?: number, cancelled: () => boolean, + onProcess: (child: ChildProcess | undefined) => void, +}): Promise { + await new Promise((resolve, reject) => { + const child = spawn(options.executable, [options.launcher, ...options.args], { + env: options.env, cwd: options.home, detached: process.platform !== 'win32', + windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], + }) + options.onProcess(child) + let output = '', settled = false, cleanupConfirmed = false, cause: string | undefined + let cleanup = Promise.resolve() + const record = (text: string): void => { output = `${output}${text}`.slice(-16_384) } + const stdout = createRuntimeOutputRedactor(record), stderr = createRuntimeOutputRedactor(record) + const finish = async (code: number | null): Promise => { + if (settled) return + settled = true; clearTimeout(timer) + stdout.end(); stderr.end() + try { await cleanup } catch { cause ??= '本机安装进程停止尚未确认' } + if (!child.pid || cleanupConfirmed) options.onProcess(undefined) + if (!cause && !options.cancelled() && code === 0) { resolve(); return } + let detail = officialCommandFailureDetail(code, output, options.cancelled() ? '插件安装已取消' : cause) + try { + mkdirSync(options.logDirectory, { recursive: true }) + const path = join(options.logDirectory, 'plugin-operation-failure.log') + writeFileSync(path, `${new Date().toISOString()}\n${detail}\n\n最近的工具输出:\n${output}\n`, { mode: 0o600 }) + detail += `\n\n错误记录:${path}` + } catch { /* Keep the real cause even if its local record cannot be written. */ } + reject(new Error(detail)) + } + const timer = setTimeout(() => { + cause = '插件依赖更新超时,请检查网络后重试' + cleanup = stopOwnedProcess(child).then(() => { cleanupConfirmed = true }, () => { cause += ';本机安装进程停止尚未确认' }) + void cleanup.then(() => finish(child.exitCode)) + }, options.timeoutMs ?? 240_000) + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.stdout.once('end', () => stdout.end()) + child.stderr.once('end', () => stderr.end()) + child.once('error', error => { cause = `无法启动插件安装工具:${error.message}`; void finish(null) }) + // Kill only our descendants promptly; wait for close to capture the final stderr. + child.once('exit', () => { + cleanup = Promise.resolve().then(() => cleanOwnedProcessTree(child)) + .then(() => { cleanupConfirmed = true }, () => { cause ??= '本机安装进程停止尚未确认' }) + }) + child.once('close', code => { + if (!settled) void finish(code) + else void cleanup.then(() => { if (cleanupConfirmed) options.onProcess(undefined) }) + }) + }) +} diff --git a/apps/desktop/src/plugin-archive-cache.ts b/apps/desktop/src/plugin-archive-cache.ts index 8405ea4d4c..11bd990a3a 100644 --- a/apps/desktop/src/plugin-archive-cache.ts +++ b/apps/desktop/src/plugin-archive-cache.ts @@ -1,8 +1,17 @@ -import { join } from 'node:path' -import { writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { readFileSync, writeFileSync } from 'node:fs' /** Delegate to the bundled pnpm after retaining local archives in this account's Home. */ export function cachedPackageManager(home: string, pnpm: string): string { + const manifest = JSON.parse(readFileSync(join(dirname(pnpm), '..', 'package.json'), 'utf8')) as { name?: unknown, version?: unknown } + if (manifest.name !== 'pnpm' || typeof manifest.version !== 'string' || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/u.test(manifest.version)) { + throw new Error('内置插件安装工具清单无效') + } + // The application supplies this executable. A Profile's packageManager pin + // must not make pnpm fetch or run another engine before the official command. + const fixedManager = Number(manifest.version.split('.')[0]) >= 11 + ? ['--pm-on-fail=ignore', '--config.verify-deps-before-run=false'] + : ['--config.manage-package-manager-versions=false', '--config.package-manager-strict=false'] const runner = join(home, '.workdsh-package-manager.mjs') writeFileSync(runner, String.raw`import { createHash } from 'node:crypto'; import { mkdirSync, readFileSync, writeFileSync, statSync } from 'node:fs'; @@ -27,7 +36,7 @@ if (args.includes('add')) { args[i] = destination; } } -const child = spawn(process.execPath, [${JSON.stringify(pnpm)}, ...args], { stdio: 'inherit', windowsHide: true }); +const child = spawn(process.execPath, [${JSON.stringify(pnpm)}, ...${JSON.stringify(fixedManager)}, ...args], { stdio: 'inherit', windowsHide: true }); for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => child.kill(signal)); child.on('error', error => { console.error(error.message); process.exitCode = 1; }); child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); }); diff --git a/apps/desktop/src/runtime-diagnostics.ts b/apps/desktop/src/runtime-diagnostics.ts index 97da0b639d..c70d24128f 100644 --- a/apps/desktop/src/runtime-diagnostics.ts +++ b/apps/desktop/src/runtime-diagnostics.ts @@ -64,3 +64,15 @@ export function runtimeFailureDetail(ready: boolean, code: number | null, output export function runtimeFailureLog(ready: boolean, code: number | null, output: string, cleanupFailed = false): string { return `${new Date().toISOString()}\n${runtimeFailureDetail(ready, code, output, cleanupFailed)}\n\n最近的运行输出:\n${redactRuntimeOutput(output).trim().slice(-16_384)}\n`; } + +export function officialCommandFailureDetail(code: number | null, output: string, cause?: string): string { + const incompatible = /Plugin (\S+) is incompatible with dsh ([\w.+-]+)/u.exec(output); + if (incompatible && !cause) { + return `插件 ${incompatible[1]} 与当前 DSH ${incompatible[2]} 不兼容。\n\n请安装适配当前底座的插件版本。${/restored package\.json, pnpm-lock\.yaml, and node_modules/u.test(output) ? '原有插件清单和依赖已恢复。' : '本次插件更新未完成。'}`; + } + const lines = redactRuntimeOutput(output).replace(/\u001b\[[0-9;]*m/gu, '').split('\n') + .map(line => line.trim()).filter(line => line && !/^(?:at\s|file:\/\/)/u.test(line)); + const errors = lines.filter(line => /(?:ERR_|\bError\b|\bfailed\b|EACCES|EPERM|ENOENT|ENOTFOUND|EAI_AGAIN)/iu.test(line)); + const reasons = (errors.length ? errors : lines).slice(-3).map(line => line.slice(0, 240)); + return [cause ?? `插件依赖更新失败 (${code})`, reasons.length ? reasons.join('\n') : '未取得工具输出,请查看本机错误记录后重试。'].join('\n\n'); +} diff --git a/apps/desktop/src/workdsh-main.ts b/apps/desktop/src/workdsh-main.ts index 0d027229eb..f7dafd1a01 100644 --- a/apps/desktop/src/workdsh-main.ts +++ b/apps/desktop/src/workdsh-main.ts @@ -21,6 +21,7 @@ import { manageCommand } from './command-management.ts' import { createRuntimeOutputRedactor, redactRuntimeOutput, runtimeFailureDetail, runtimeFailureLog } from './runtime-diagnostics.ts' import { cleanOwnedProcessTree, stopOwnedProcess } from './owned-process.ts' import { prepareRuntimePort, runtimePortConflict } from './runtime-port.ts' +import { runOfficialCommand } from './official-command.ts' const PROFILE_NAME = 'workdsh' const READY_PATTERN = /dsh web:\s+(http:\/\/127\.0\.0\.1:\d+\/?\?token=[^\s]+)/u @@ -189,21 +190,9 @@ function runtimeEnvironment(home: string): NodeJS.ProcessEnv { async function officialCommand(home: string, args: string[], timeoutMs = 240_000): Promise { const executable = bundledNodeExecutable() const launcher = officialLauncher(bundledProfileDirectory(), home, executable, join(bundledPrimaryRuntime(), 'dependencies', 'pnpm', 'bin', 'pnpm.cjs')) - await new Promise((resolve, reject) => { - const child = spawn(executable, [launcher, ...args], { env: runtimeEnvironment(home), cwd: home, detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) - installer = child - let settled = false - const timer = setTimeout(() => { void stopOwnedProcess(child).then(() => finish(new Error('官方插件安装超时,请检查网络后重试')), finish) }, timeoutMs) - function finish(error?: Error): void { - if (settled) return - settled = true; clearTimeout(timer) - if (installer === child) installer = undefined - void cleanOwnedProcessTree(child).then(() => error ? reject(error) : resolve(), reject) - } - child.stdout.on('data', () => {}) - child.stderr.on('data', () => {}) - child.once('error', () => finish(new Error('无法启动官方插件安装工具'))) - child.once('exit', code => finish(quitRequested ? new Error('插件安装已取消') : code === 0 ? undefined : new Error(`官方插件操作失败 (${code});请核对插件版本和网络`))) + await runOfficialCommand({ executable, launcher, args, home, env: runtimeEnvironment(home), timeoutMs, + logDirectory: join(app.getPath('userData'), 'logs'), cancelled: () => quitRequested, + onProcess: child => { installer = child }, }) } diff --git a/apps/desktop/tests/connection-lifecycle.spec.ts b/apps/desktop/tests/connection-lifecycle.spec.ts index c150bb58bf..767849026d 100644 --- a/apps/desktop/tests/connection-lifecycle.spec.ts +++ b/apps/desktop/tests/connection-lifecycle.spec.ts @@ -51,8 +51,10 @@ it('enforces the packaged backend, uses explicitly installed enterprise identity writeFileSync(join(source, 'node_modules/@deepseek-ai/dsh/lib/profile-boot.js'), '') writeFileSync(join(source, 'node_modules/@deepseek-ai/dsh-app-boot/lib/index.js'), '') writeFileSync(join(source, 'node_modules/pnpm/bin/pnpm.cjs'), '') + writeFileSync(join(source, 'node_modules/pnpm/package.json'), JSON.stringify({ name: 'pnpm', version: '11.28.5' })) mkdirSync(join(root, 'dependencies/pnpm/bin'), { recursive: true }) writeFileSync(join(root, 'dependencies/pnpm/bin/pnpm.cjs'), '') + writeFileSync(join(root, 'dependencies/pnpm/package.json'), JSON.stringify({ name: 'pnpm', version: '11.28.5' })) writeFileSync(join(source, 'package.json'), JSON.stringify({ dependencies: {}, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base'] } } })) writeFileSync(join(source, 'profile-installation.json'), readFileSync(join(source, 'package.json'))) mkdirSync(home); writeFileSync(join(home, 'retained'), 'personal data') diff --git a/apps/desktop/tests/local-runtime.spec.ts b/apps/desktop/tests/local-runtime.spec.ts index 1b4754140b..465b52c0ea 100644 --- a/apps/desktop/tests/local-runtime.spec.ts +++ b/apps/desktop/tests/local-runtime.spec.ts @@ -17,7 +17,8 @@ function fixture() { mkdirSync(join(source, 'node_modules/@deepseek-ai/dsh-app-boot/lib'), { recursive: true }) writeFileSync(join(source, 'node_modules/@deepseek-ai/dsh-app-boot/lib/index.js'), '') writeFileSync(join(source, 'node_modules/pnpm/bin/pnpm.cjs'), '') - const base = { dependencies: { '@deepseek-ai/dsh': '0.2.0-rc.2', 'workdsh-plugin-skills': '1.0.0' }, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', 'workdsh-plugin-skills'] } } } + writeFileSync(join(source, 'node_modules/pnpm/package.json'), JSON.stringify({ name: 'pnpm', version: '11.7.0' })) + const base = { packageManager: 'pnpm@11.7.0', dependencies: { '@deepseek-ai/dsh': '0.2.0-rc.2', 'workdsh-plugin-skills': '1.0.0' }, dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', 'workdsh-plugin-skills'] } } } writeFileSync(join(source, 'package.json'), JSON.stringify(base)) writeFileSync(join(source, 'profile-installation.json'), JSON.stringify(base)) return { root, source, home, base } @@ -98,3 +99,45 @@ it('retries a failed version-policy installation after restart until official in markProfileUpdated(source, profile) expect(materializeRuntimeProfile(source, home).changed).toBe(false) }) + +it('migrates an unchanged platform package manager and retains a pending install until success', () => { + const { source, home, base } = fixture() + const { profile } = materializeRuntimeProfile(source, home) + const manifest = join(profile, 'package.json'), state = join(profile, '.workdsh-desktop-base.json') + const user = JSON.parse(readFileSync(manifest, 'utf8')) + user.dependencies['user-plugin'] = 'file:custom.tgz' + writeFileSync(manifest, JSON.stringify(user)) + writeFileSync(join(profile, 'pnpm-lock.yaml'), 'user lock remains owned') + const previous = JSON.parse(readFileSync(state, 'utf8')) + previous.pendingInstallation = true + writeFileSync(state, JSON.stringify(previous)) + base.packageManager = 'pnpm@11.28.5' + writeFileSync(join(source, 'package.json'), JSON.stringify(base)) + for (let attempt = 0; attempt < 2; attempt++) { + expect(materializeRuntimeProfile(source, home).changed).toBe(true) + const migrated = JSON.parse(readFileSync(manifest, 'utf8')) + expect(migrated.packageManager).toBe('pnpm@11.28.5') + expect(migrated.dependencies).toEqual({ 'user-plugin': 'file:custom.tgz' }) + expect(readFileSync(join(profile, 'pnpm-lock.yaml'), 'utf8')).toBe('user lock remains owned') + expect(JSON.parse(readFileSync(state, 'utf8')).pendingInstallation).toBe(true) + } + markProfileUpdated(source, profile) + expect(materializeRuntimeProfile(source, home).changed).toBe(false) + expect(JSON.parse(readFileSync(state, 'utf8')).base.packageManager).toBe('pnpm@11.28.5') +}) + +it.each(['pnpm@10.34.5', 'yarn@4.18.0', undefined])('keeps a user-owned package manager declaration or removal (%s)', custom => { + const { source, home, base } = fixture() + const { profile } = materializeRuntimeProfile(source, home) + const manifest = join(profile, 'package.json') + const user = JSON.parse(readFileSync(manifest, 'utf8')) + if (custom === undefined) delete user.packageManager + else user.packageManager = custom + writeFileSync(manifest, JSON.stringify(user)) + base.packageManager = 'pnpm@11.28.5' + writeFileSync(join(source, 'package.json'), JSON.stringify(base)) + expect(materializeRuntimeProfile(source, home).changed).toBe(false) + expect(JSON.parse(readFileSync(manifest, 'utf8')).packageManager).toBe(custom) + expect(materializeRuntimeProfile(source, home).changed).toBe(false) + expect(JSON.parse(readFileSync(manifest, 'utf8')).packageManager).toBe(custom) +}) diff --git a/apps/desktop/tests/official-command.spec.ts b/apps/desktop/tests/official-command.spec.ts new file mode 100644 index 0000000000..725b389efd --- /dev/null +++ b/apps/desktop/tests/official-command.spec.ts @@ -0,0 +1,50 @@ +import { afterEach, expect, test, vi } from 'vitest' +import { type ChildProcess } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { runOfficialCommand } from '../src/official-command.ts' +import * as owned from '../src/owned-process.ts' + +const homes: string[] = [] +afterEach(() => { vi.restoreAllMocks(); for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) }) +function fixture(source: string) { + const home = mkdtempSync(join(tmpdir(), 'workdsh-cli-diagnostic-')); homes.push(home) + const launcher = join(home, 'cli.mjs'); writeFileSync(launcher, source) + return { executable: process.execPath, launcher, args: [], home, env: {}, + logDirectory: join(home, 'logs'), cancelled: () => false, onProcess: () => {} } +} + +test('retains final CLI errors and redacts credentials split across writes', async () => { + const options = fixture(`process.stderr.write('Bearer sens'); setTimeout(() => { process.stderr.write('itive-value\\nERR_PNPM_PACKAGE_MANAGER_BAD_VERSION expected 11.7.0\\n'); process.exitCode = 1 }, 10)`) + await expect(runOfficialCommand(options)).rejects.toThrow('ERR_PNPM_PACKAGE_MANAGER_BAD_VERSION') + const log = readFileSync(join(options.logDirectory, 'plugin-operation-failure.log'), 'utf8') + expect(log).toContain('expected 11.7.0') + expect(log).toContain('[redacted]') + expect(log).not.toContain('sensitive-value') +}) + +test('successful CLI completion does not report an installation failure', async () => { + await expect(runOfficialCommand(fixture(`console.log('done')`))).resolves.toBeUndefined() +}) + +test('timeout terminates its owned process and preserves its specific reason', async () => { + const options = fixture(`console.error('install started'); setInterval(() => {}, 1000)`) + await expect(runOfficialCommand({ ...options, timeoutMs: 150 })).rejects.toThrow('插件依赖更新超时') + expect(readFileSync(join(options.logDirectory, 'plugin-operation-failure.log'), 'utf8')).toContain('install started') +}) + +test('retains the owned process handle if timeout cleanup cannot confirm it stopped', async () => { + vi.spyOn(owned, 'stopOwnedProcess').mockRejectedValue(new Error('not stopped')) + const options = fixture(`setInterval(() => {}, 1000)`) + let tracked: ChildProcess | undefined + try { + await expect(runOfficialCommand({ ...options, timeoutMs: 150, onProcess: child => { tracked = child } })).rejects.toThrow('停止尚未确认') + expect(tracked?.pid).toBeTypeOf('number') + expect(tracked?.exitCode).toBeNull() + } finally { + const child = tracked + if (child) await new Promise(resolve => { child.once('close', resolve); child.kill('SIGKILL') }) + } + await vi.waitFor(() => expect(tracked).toBeUndefined()) +}) diff --git a/apps/desktop/tests/plugin-archive-cache.spec.ts b/apps/desktop/tests/plugin-archive-cache.spec.ts index defa988fee..4ad6f33780 100644 --- a/apps/desktop/tests/plugin-archive-cache.spec.ts +++ b/apps/desktop/tests/plugin-archive-cache.spec.ts @@ -7,26 +7,43 @@ import { cachedPackageManager } from '../src/plugin-archive-cache.ts' const homes: string[] = [] afterEach(() => { for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) }) +function fakePnpm(home: string, version = '11.28.5'): string { + const directory = join(home, 'engine'), entry = join(directory, 'bin', 'pnpm.mjs') + mkdirSync(join(directory, 'bin'), { recursive: true }) + writeFileSync(join(directory, 'package.json'), JSON.stringify({ name: 'pnpm', version, bin: { pnpm: 'bin/pnpm.mjs' } })) + writeFileSync(entry, 'console.log(JSON.stringify(process.argv.slice(2)))') + return entry +} +const fixedPnpm11 = ['--pm-on-fail=ignore', '--config.verify-deps-before-run=false'] test('local archives are retained by content and pnpm records the managed path', () => { const home = mkdtempSync(join(tmpdir(), 'workdsh-plugin-cache-')); homes.push(home) - const fake = join(home, 'pnpm.mjs') - writeFileSync(fake, 'console.log(JSON.stringify(process.argv.slice(2)))') + const fake = fakePnpm(home) const source = join(home, 'download with spaces.tgz'); writeFileSync(source, 'archive content') const runner = cachedPackageManager(home, fake) - const args = JSON.parse(execFileSync(process.execPath, [runner, 'add', source, '--save-exact'], { encoding: 'utf8' })) as string[] + const received = JSON.parse(execFileSync(process.execPath, [runner, 'add', source, '--save-exact'], { encoding: 'utf8' })) as string[] + expect(received.slice(0, 2)).toEqual(fixedPnpm11) + const args = received.slice(2) expect(args[0]).toBe('add'); expect(args[2]).toBe('--save-exact') expect(args[1]).toMatch(/plugin-archives[/\\][a-f0-9]{64}\.tgz$/) rmSync(source) expect(readFileSync(args[1]!, 'utf8')).toBe('archive content') - expect(JSON.parse(execFileSync(process.execPath, [runner, 'install', '--frozen-lockfile'], { encoding: 'utf8' }))).toEqual(['install', '--frozen-lockfile']) + expect(JSON.parse(execFileSync(process.execPath, [runner, 'install', '--frozen-lockfile'], { encoding: 'utf8' }))).toEqual([...fixedPnpm11, 'install', '--frozen-lockfile']) writeFileSync(args[1]!, 'corrupted'); writeFileSync(source, 'archive content') expect(() => execFileSync(process.execPath, [runner, 'add', source], { stdio: 'pipe' })).toThrow() }) test('registry installs pass through and personal/member caches are separate', () => { const home = mkdtempSync(join(tmpdir(), 'workdsh-plugin-cache-')); homes.push(home) - const fake = join(home, 'pnpm.mjs'); writeFileSync(fake, 'console.log(JSON.stringify(process.argv.slice(2)))') + const fake = fakePnpm(home) const member = join(home, 'member'); mkdirSync(member) const personalRunner = cachedPackageManager(home, fake), memberRunner = cachedPackageManager(member, fake) expect(memberRunner).not.toBe(personalRunner) - expect(JSON.parse(execFileSync(process.execPath, [memberRunner, 'add', '@company/plugin@1.0.0'], { encoding: 'utf8' }))).toEqual(['add', '@company/plugin@1.0.0']) + expect(JSON.parse(execFileSync(process.execPath, [memberRunner, 'add', '@company/plugin@1.0.0'], { encoding: 'utf8' }))).toEqual([...fixedPnpm11, 'add', '@company/plugin@1.0.0']) +}) + +test('uses the older pnpm policy only with an older bundled engine and rejects invalid metadata', () => { + const home = mkdtempSync(join(tmpdir(), 'workdsh-plugin-cache-')); homes.push(home) + const fake = fakePnpm(home, '10.34.5'), runner = cachedPackageManager(home, fake) + expect(JSON.parse(execFileSync(process.execPath, [runner, 'install'], { encoding: 'utf8' }))).toEqual(['--config.manage-package-manager-versions=false', '--config.package-manager-strict=false', 'install']) + writeFileSync(join(home, 'engine', 'package.json'), JSON.stringify({ name: 'another-manager', version: '11.28.5' })) + expect(() => cachedPackageManager(home, fake)).toThrow('内置插件安装工具清单无效') }) diff --git a/apps/desktop/tests/runtime-diagnostics.spec.ts b/apps/desktop/tests/runtime-diagnostics.spec.ts index 5a46c926a6..3a985d3ed0 100644 --- a/apps/desktop/tests/runtime-diagnostics.spec.ts +++ b/apps/desktop/tests/runtime-diagnostics.spec.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { createRuntimeOutputRedactor, redactRuntimeOutput, runtimeFailureDetail, runtimeFailureLog } from '../src/runtime-diagnostics.ts' +import { createRuntimeOutputRedactor, redactRuntimeOutput, runtimeFailureDetail, runtimeFailureLog, officialCommandFailureDetail } from '../src/runtime-diagnostics.ts' it('keeps startup cause when descendant cleanup also fails', () => { const detail = runtimeFailureDetail(false, 1, 'stderr: missing plugin entry', true) @@ -46,3 +46,11 @@ it('summarizes simultaneous startup causes while retaining the redacted trace in expect(log).not.toContain('sensitive-test-value'); expect(log).toContain('[redacted]'); }) + +it('explains an official compatibility rejection without suggesting a network failure or version bypass', () => { + const detail = officialCommandFailureDetail(1, 'dsh: installation rejected: Plugin sigmapad-plugin@0.1.0-alpha.180 is incompatible with dsh 0.2.1-rc.1: requirements {"sdk":"0.2.0-rc.2"}. To accept this risk use allow-version\ndsh: restored package.json, pnpm-lock.yaml, and node_modules.\ndsh: plugin command failed; diagnostics: /local/log') + expect(detail).toContain('sigmapad-plugin@0.1.0-alpha.180') + expect(detail).toContain('DSH 0.2.1-rc.1 不兼容') + expect(detail).toContain('已恢复') + expect(detail).not.toMatch(/网络|allow-version/) +}) diff --git a/apps/desktop/tests/verify-workdsh-carrier.spec.ts b/apps/desktop/tests/verify-workdsh-carrier.spec.ts index a1fdb4f987..5980a9ddf4 100644 --- a/apps/desktop/tests/verify-workdsh-carrier.spec.ts +++ b/apps/desktop/tests/verify-workdsh-carrier.spec.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from 'vitest' -import { normalizeAsarEntry } from '../scripts/verify-workdsh-carrier.ts' +import { cpSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, type Stats } from 'node:fs' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { normalizeAsarEntry, verifyPackagedOwnedExports } from '../scripts/verify-workdsh-carrier.ts' + +// Use electron-builder's real matcher without importing its unrelated target +// declaration graph (the published SnapOptions declaration has a missing file). +const { FileMatcher } = createRequire(import.meta.url)('app-builder-lib/out/fileMatcher.js') as { + FileMatcher: new (from: string, to: string, expand: (pattern: string) => string, patterns: string[]) => { + createFilter(): (path: string, stat: Stats) => boolean + } +} describe('ASAR inventory paths', () => { it('recognizes the Electron entry point on Windows and macOS', () => { @@ -9,3 +21,50 @@ describe('ASAR inventory paths', () => { .toBe('node_modules/@deepseek-ai/dsh/package.json') }) }) + +describe('final copied public SDK exports', () => { + it('uses the real resource filter to preserve declarations, and rejects a missing copied SDK type', () => { + const root = mkdtempSync(join(tmpdir(), 'workdsh-copied-sdk-')) + const source = join(root, 'build/workdsh-runtime') + const resources = join(root, 'WorkDSH.app/Contents/Resources/workdsh-runtime') + const relativeProfile = 'profiles/workdsh' + const sdk = 'workdsh-plugin-workbenches' + const relativePackage = join(relativeProfile, 'node_modules', sdk) + try { + const files: Record = { + 'package.json': JSON.stringify({ name: sdk, exports: { + '.': { types: './dist/index.d.ts', default: './dist/index.js' }, + './client': { types: './dist/client.d.mts', default: './dist/client.browser.js' }, + './compat': { types: './dist/compat.d.cts', default: './dist/compat.cjs' }, + } }), + 'dist/index.d.ts': 'export interface Host {}', + 'dist/index.js': 'export {};', + 'dist/client.d.mts': 'export interface Client {}', + 'dist/client.browser.js': '', + 'dist/compat.d.cts': 'export interface Compat {}', + 'dist/compat.cjs': 'module.exports={};', + } + for (const [file, text] of Object.entries(files)) { + const target = join(source, relativePackage, file) + mkdirSync(dirname(target), { recursive: true }); writeFileSync(target, text) + } + const officialType = join(relativeProfile, 'node_modules/@deepseek-ai/dsh-client-ui-conversation/lib/types/client/index.d.ts') + mkdirSync(dirname(join(source, officialType)), { recursive: true }) + writeFileSync(join(source, officialType), 'export interface OfficialConversation {}') + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { + build: { extraResources: Array<{ from: string; filter?: string[] }> } + } + const patterns = manifest.build.extraResources.find(item => item.from === 'build/workdsh-runtime')?.filter + expect(patterns).toBeDefined() + const filter = new FileMatcher(source, resources, pattern => pattern, patterns!).createFilter() + cpSync(source, resources, { recursive: true, filter: path => path === source || filter(path, lstatSync(path)) }) + const copiedProfile = join(resources, relativeProfile) + expect(() => verifyPackagedOwnedExports(copiedProfile, [sdk])).not.toThrow() + expect(readFileSync(join(resources, officialType), 'utf8')).toContain('OfficialConversation') + rmSync(join(resources, relativePackage, 'dist/index.d.ts')) + expect(() => verifyPackagedOwnedExports(copiedProfile, [sdk])).toThrow('Missing built export ./dist/index.d.ts') + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) +}) From 7029c9e121b7b20bd56cb2dd80b560d7985999a6 Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Sun, 18 Oct 2026 13:14:47 +0800 Subject: [PATCH 4/7] Resolve shared SDK from installation context and resume interrupted AI creation --- apps/desktop/src/official-command.ts | 4 +- apps/desktop/src/owned-process.ts | 120 ++++++++++++++-- apps/desktop/src/workdsh-main.ts | 4 +- .../tests/connection-lifecycle.spec.ts | 2 +- apps/desktop/tests/official-command.spec.ts | 11 +- .../tests/owned-process-windows.spec.ts | 136 +++++++++++++++--- apps/desktop/tests/owned-process.spec.ts | 4 +- .../workbenches/scripts/check-startup.mjs | 32 ++++- .../workbenches/scripts/run-delivery.mjs | 21 +++ .../src/client/WorkbenchCreationPage.tsx | 29 +++- .../src/client/WorkbenchRequirementsForm.tsx | 3 +- .../workbenches/src/client/creation-client.ts | 10 +- .../workbenches/src/client/creation-review.ts | 24 ++++ .../src/client/requirements-save-error.ts | 12 ++ .../src/runtime/creation-development.ts | 29 ++++ .../workbenches/src/runtime/creation-host.ts | 12 +- .../workbenches/src/runtime/creation-rpc.ts | 5 +- .../src/runtime/development-starter.ts | 10 +- .../src/runtime/profile-development-source.ts | 33 ++++- .../tests/creation-development.test.mjs | 43 ++++++ .../tests/creation-review.test.mjs | 30 ++++ .../workbenches/tests/creation-rpc.test.mjs | 11 +- .../tests/delivery-runner.test.mjs | 10 ++ .../tests/development-starter.test.mjs | 32 ++++- .../tests/profile-development-source.test.mjs | 27 +++- .../tests/requirements-save-error.test.mjs | 13 ++ .../workbenches/tests/startup-check.test.mjs | 37 +++++ 27 files changed, 630 insertions(+), 74 deletions(-) create mode 100644 packages/plugins/workbenches/src/client/creation-review.ts create mode 100644 packages/plugins/workbenches/src/client/requirements-save-error.ts create mode 100644 packages/plugins/workbenches/src/runtime/creation-development.ts create mode 100644 packages/plugins/workbenches/tests/creation-development.test.mjs create mode 100644 packages/plugins/workbenches/tests/creation-review.test.mjs create mode 100644 packages/plugins/workbenches/tests/requirements-save-error.test.mjs diff --git a/apps/desktop/src/official-command.ts b/apps/desktop/src/official-command.ts index 217bbcddaf..aa9c58c2fa 100644 --- a/apps/desktop/src/official-command.ts +++ b/apps/desktop/src/official-command.ts @@ -1,7 +1,7 @@ import { spawn, type ChildProcess } from 'node:child_process' import { mkdirSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { cleanOwnedProcessTree, stopOwnedProcess } from './owned-process.ts' +import { cleanOwnedProcessTree, stopOwnedProcess, trackOwnedProcess } from './owned-process.ts' import { createRuntimeOutputRedactor, officialCommandFailureDetail } from './runtime-diagnostics.ts' /** Preserve the actual CLI failure before returning control to the entry screen. */ @@ -11,10 +11,12 @@ export async function runOfficialCommand(options: { onProcess: (child: ChildProcess | undefined) => void, }): Promise { await new Promise((resolve, reject) => { + const startedAt = Date.now() const child = spawn(options.executable, [options.launcher, ...options.args], { env: options.env, cwd: options.home, detached: process.platform !== 'win32', windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], }) + trackOwnedProcess(child, startedAt) options.onProcess(child) let output = '', settled = false, cleanupConfirmed = false, cause: string | undefined let cleanup = Promise.resolve() diff --git a/apps/desktop/src/owned-process.ts b/apps/desktop/src/owned-process.ts index 7db23335fd..3dbd4e545e 100644 --- a/apps/desktop/src/owned-process.ts +++ b/apps/desktop/src/owned-process.ts @@ -1,7 +1,111 @@ /** Stop only the process tree created by this carrier. A parent exit is not a tree exit. */ import { spawn, type ChildProcess } from 'node:child_process' +import { join } from 'node:path' +export interface WindowsProcessIdentity { pid: number, ppid: number, createdAt: number } +interface Ownership { pid: number, startedAt: number, registeredAt: number, exitedAt?: number, known: WindowsProcessIdentity[] } +const ownership = new WeakMap() const cleaned = new WeakMap>() + +/** Call immediately after spawn, with the time captured immediately before it. */ +export function trackOwnedProcess(child: ChildProcess, startedAt: number): void { + if (!child.pid || ownership.has(child)) return + const state: Ownership = { pid: child.pid, startedAt, registeredAt: Date.now(), known: [] } + ownership.set(child, state) + child.once('exit', () => { state.exitedAt = Date.now() }) +} + +/** Parent IDs alone are insufficient after PID reuse; bound each generation. */ +export function selectOwnedWindowsProcesses(rows: WindowsProcessIdentity[], owner: Omit, known: WindowsProcessIdentity[] = []): WindowsProcessIdentity[] { + const generations = new Map(known.map(row => [row.pid, row])) + const current = new Map(rows.map(row => [row.pid, row])) + const root = current.get(owner.pid) + if (root && root.createdAt < owner.startedAt) return [] // Unrelated or inconsistent generation; never claim its tree. + if (root && root.createdAt >= owner.startedAt && root.createdAt <= owner.registeredAt) generations.set(root.pid, root) + const rootBirth = generations.get(owner.pid)?.createdAt ?? owner.startedAt + // A new process may already reuse the root PID. Its children cannot belong + // to the exited owner; older orphaned descendants can still be reclaimed. + const rootEnd = Math.min(owner.exitedAt ?? Infinity, root && root.createdAt > owner.registeredAt ? root.createdAt - 1 : Infinity) + for (let pass = 0; pass < 128; pass++) { + let added = false + for (const row of rows) { + if (row.pid === owner.pid || generations.has(row.pid)) continue + const parent = generations.get(row.ppid) + if (row.ppid !== owner.pid && !parent) continue + const birth = row.ppid === owner.pid ? rootBirth : parent!.createdAt + const presentParent = current.get(row.ppid) + const end = row.ppid === owner.pid ? rootEnd : presentParent && presentParent.createdAt !== parent!.createdAt ? presentParent.createdAt - 1 : Infinity + if (row.createdAt < birth || row.createdAt > end) continue + generations.set(row.pid, row); added = true + } + if (!added) break + } + return [...generations.values()].filter(row => current.get(row.pid)?.createdAt === row.createdAt) +} + +async function windowsCommand(script: string, timeoutMs: number): Promise { + const executable = process.env.SystemRoot ? join(process.env.SystemRoot, 'System32/WindowsPowerShell/v1.0/powershell.exe') : 'powershell.exe' + return new Promise((resolve, reject) => { + const task = spawn(executable, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }) + let output = '', error = '', settled = false + const finish = (failure?: Error): void => { + if (settled) return + settled = true; clearTimeout(timer) + failure ? reject(failure) : resolve(output.trim()) + } + const timer = setTimeout(() => { task.kill(); finish(new Error('Windows 本机进程清理查询超时')) }, timeoutMs) + task.stdout!.on('data', chunk => { output += String(chunk); if (output.length > 262_144) { task.kill(); finish(new Error('Windows 本机进程清理查询超出限制')) } }) + task.stderr!.on('data', chunk => { error = (error + String(chunk)).slice(-2048) }) + task.once('error', () => finish(new Error('Windows 本机进程清理工具不可用'))) + task.once('close', code => finish(code === 0 ? undefined : new Error(`Windows 本机进程清理查询失败 (${code})${/access|denied|unauthorized/iu.test(error) ? ',请核对权限' : ''}`))) + }) +} +async function windowsSnapshot(pids: number[], timeoutMs: number): Promise { + const filter = [...new Set(pids)].map(pid => `(ProcessId = ${pid} OR ParentProcessId = ${pid})`).join(' OR ') + const output = await windowsCommand(`$ErrorActionPreference='Stop'; $rows=@(Get-CimInstance Win32_Process -Filter '${filter}' -Property ProcessId,ParentProcessId,CreationDate | ForEach-Object { [pscustomobject]@{pid=[int]$_.ProcessId;ppid=[int]$_.ParentProcessId;createdAt=([DateTimeOffset]$_.CreationDate).ToUnixTimeMilliseconds()} }); ConvertTo-Json -InputObject $rows -Compress`, timeoutMs) + const rows: unknown = JSON.parse(output) + if (!Array.isArray(rows) || rows.some(row => !row || !Number.isInteger(row.pid) || !Number.isInteger(row.ppid) || !Number.isSafeInteger(row.createdAt) || row.pid <= 0)) throw new Error('Windows 本机进程清理查询结果无效') + return rows as WindowsProcessIdentity[] +} +async function windowsTaskkill(row: WindowsProcessIdentity, timeoutMs: number): Promise<{ status: string, code?: number, output?: string }> { + // Recheck CreationDate in the same public OS command that invokes taskkill. + const output = await windowsCommand(`$ErrorActionPreference='Stop'; $current=Get-CimInstance Win32_Process -Filter 'ProcessId = ${row.pid}' -Property ProcessId,CreationDate; if($null -eq $current){ConvertTo-Json @{status='missing'} -Compress;exit 0}; if(([DateTimeOffset]$current.CreationDate).ToUnixTimeMilliseconds() -ne ${row.createdAt}){ConvertTo-Json @{status='reused'} -Compress;exit 0}; $ErrorActionPreference='Continue'; $text=@(& "$env:SystemRoot\\System32\\taskkill.exe" /PID ${row.pid} /T /F 2>&1); $result=$LASTEXITCODE; $ErrorActionPreference='Stop'; ConvertTo-Json @{status='taskkill';code=$result;output=($text|Out-String)} -Compress`, timeoutMs) + const result = JSON.parse(output) as { status?: unknown, code?: unknown, output?: unknown } + if (!['missing', 'reused', 'taskkill'].includes(String(result.status)) || (result.status === 'taskkill' && !Number.isInteger(result.code))) throw new Error('Windows 本机进程清理结果无效') + return result as { status: string, code?: number, output?: string } +} +function processMissing(pid: number): boolean { + try { process.kill(pid, 0); return false } catch (error) { return (error as NodeJS.ErrnoException).code === 'ESRCH' } +} +async function cleanWindowsTree(child: ChildProcess): Promise { + const state = ownership.get(child) + if (!state) throw new Error('Windows 本机进程缺少所有权记录,未停止未知进程') + const deadline = Date.now() + 12_000 + const budget = (): number => { const remaining = deadline - Date.now(); if (remaining <= 0) throw new Error('Windows 本机进程树清理超时'); return Math.min(4000, remaining) } + for (let pass = 0; pass < 8; pass++) { + const queried = new Set([state.pid, ...state.known.map(row => row.pid)]) + const rows = await windowsSnapshot([...queried], budget()) + const selected = selectOwnedWindowsProcesses(rows, state, state.known) + for (const row of selected) if (!state.known.some(known => known.pid === row.pid && known.createdAt === row.createdAt)) state.known.push(row) + if (state.known.length > 128) throw new Error('Windows 本机进程树超出安全清理范围') + if (!selected.length) { + const current = rows.find(row => row.pid === state.pid) + if (current && (current.createdAt < state.startedAt || current.createdAt > state.registeredAt)) return // New generation, never ours. + if (processMissing(state.pid)) return + throw new Error('Windows 本机进程树清理未确认') + } + // Discover nested descendants before removing their only visible parent. + if (selected.some(row => !queried.has(row.pid))) continue + for (const row of [...selected].sort((a, b) => b.createdAt - a.createdAt)) { + const result = await windowsTaskkill(row, budget()) + if (result.status === 'taskkill' && result.code !== 0 && !(result.code === 128 && processMissing(row.pid))) { + throw new Error(`Windows 本机进程树清理失败 (${result.code})${/access|denied|权限|拒绝/iu.test(result.output ?? '') ? ',操作被拒绝' : ''},未确认停止`) + } + } + } + throw new Error('Windows 本机进程树清理未确认') +} + export function cleanOwnedProcessTree(child: ChildProcess): Promise { const existing = cleaned.get(child) if (existing) return existing @@ -9,30 +113,22 @@ export function cleanOwnedProcessTree(child: ChildProcess): Promise { let result: Promise if (!pid) result = Promise.resolve() else if (process.platform !== 'win32') { - // Run immediately when the parent exits, while its group still belongs to its descendants. try { process.kill(-pid, 'SIGKILL') } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') throw error } result = Promise.resolve() - } else { - result = new Promise((resolve, reject) => { - const task = spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) - task.once('error', () => reject(new Error('Windows 本机进程树未能停止'))) - task.once('exit', code => code === 0 ? resolve() : reject(new Error('Windows 本机进程树清理未确认'))) - }) - } - cleaned.set(child, result) - return result + } else result = cleanWindowsTree(child) + const tracked = result.catch(error => { cleaned.delete(child); throw error }) + cleaned.set(child, tracked) + return tracked } export async function stopOwnedProcess(child: ChildProcess): Promise { if (process.platform === 'win32') { await cleanOwnedProcessTree(child); return } if (child.exitCode !== null || child.signalCode !== null) { await cleanOwnedProcessTree(child); return } await new Promise((resolve, reject) => { - // Official DSH exits after its own five-second shutdown window. Leave its exit finalizer time to run. const force = setTimeout(() => { void cleanOwnedProcessTree(child).catch(reject) }, 8000) const timeout = setTimeout(() => { clearTimeout(force); reject(new Error('本机进程尚未停止')) }, 12000) child.once('exit', () => { clearTimeout(force); clearTimeout(timeout) - // Do not cancel descendant cleanup just because the parent acknowledged SIGTERM. void cleanOwnedProcessTree(child).then(resolve, reject) }) if (child.pid) { diff --git a/apps/desktop/src/workdsh-main.ts b/apps/desktop/src/workdsh-main.ts index f7dafd1a01..17e222e19a 100644 --- a/apps/desktop/src/workdsh-main.ts +++ b/apps/desktop/src/workdsh-main.ts @@ -19,7 +19,7 @@ import { EnterpriseLogin, startEnterpriseAuthority, type Authority } from './ent import { desktopEnterprisePatch, enterpriseEnvironment, enterpriseSpace, materializeRuntimeProfile, markProfileUpdated, officialLauncher, officialHostLauncher } from './local-runtime.ts' import { manageCommand } from './command-management.ts' import { createRuntimeOutputRedactor, redactRuntimeOutput, runtimeFailureDetail, runtimeFailureLog } from './runtime-diagnostics.ts' -import { cleanOwnedProcessTree, stopOwnedProcess } from './owned-process.ts' +import { cleanOwnedProcessTree, stopOwnedProcess, trackOwnedProcess } from './owned-process.ts' import { prepareRuntimePort, runtimePortConflict } from './runtime-port.ts' import { runOfficialCommand } from './official-command.ts' @@ -228,6 +228,7 @@ async function startRuntime(home: string, connection: DesktopConnection, patches if (quitRequested) throw new Error('启动已取消') const layers = [...(existsSync(basePatch) ? [basePatch] : []), ...patches, ...(port.managed ? [port.patch] : [])] const launcher = officialHostLauncher(bundledProfileDirectory(), home, executable, join(bundledPrimaryRuntime(), 'dependencies', 'pnpm', 'bin', 'pnpm.cjs'), layers) + const startedAt = Date.now() const child = spawn(executable, [launcher, '--no-open'], { env: runtimeEnvironment(home), cwd: enterprise ? join(enterprise.root, 'workspace') : home, @@ -235,6 +236,7 @@ async function startRuntime(home: string, connection: DesktopConnection, patches stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }) + trackOwnedProcess(child, startedAt) runtime = child runtimeStarting = true let ready = false diff --git a/apps/desktop/tests/connection-lifecycle.spec.ts b/apps/desktop/tests/connection-lifecycle.spec.ts index 767849026d..e05d47a047 100644 --- a/apps/desktop/tests/connection-lifecycle.spec.ts +++ b/apps/desktop/tests/connection-lifecycle.spec.ts @@ -12,7 +12,7 @@ vi.mock('../src/enterprise-auth.ts', () => ({ startEnterpriseAuthority: async () => ({ url: 'http://127.0.0.1:19898', key: 'a'.repeat(64), close: state.closed }), })) vi.mock('../src/enterprise-plugins.ts',()=>({installedEnterprisePlugins:vi.fn(()=>['workdsh-provider-identity-enterprise','workdsh-plugin-enterprise-collaboration']),composeInstalledEnterprisePlugins:vi.fn(async()=>['workdsh-provider-identity-enterprise','workdsh-plugin-enterprise-collaboration'])})) -vi.mock('../src/owned-process.ts', () => ({ stopOwnedProcess: state.stop, cleanOwnedProcessTree: vi.fn(async () => {}) })) +vi.mock('../src/owned-process.ts', () => ({ trackOwnedProcess: vi.fn(), stopOwnedProcess: state.stop, cleanOwnedProcessTree: vi.fn(async () => {}) })) vi.mock('node:child_process', () => ({ spawn: vi.fn() })) vi.mock('electron', async () => { const { EventEmitter } = await import('node:events') diff --git a/apps/desktop/tests/official-command.spec.ts b/apps/desktop/tests/official-command.spec.ts index 725b389efd..1c8827c942 100644 --- a/apps/desktop/tests/official-command.spec.ts +++ b/apps/desktop/tests/official-command.spec.ts @@ -7,6 +7,7 @@ import { runOfficialCommand } from '../src/official-command.ts' import * as owned from '../src/owned-process.ts' const homes: string[] = [] +const integrationTimeout = process.platform === 'win32' ? 20_000 : 5_000 afterEach(() => { vi.restoreAllMocks(); for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true }) }) function fixture(source: string) { const home = mkdtempSync(join(tmpdir(), 'workdsh-cli-diagnostic-')); homes.push(home) @@ -22,17 +23,17 @@ test('retains final CLI errors and redacts credentials split across writes', asy expect(log).toContain('expected 11.7.0') expect(log).toContain('[redacted]') expect(log).not.toContain('sensitive-value') -}) +}, integrationTimeout) test('successful CLI completion does not report an installation failure', async () => { await expect(runOfficialCommand(fixture(`console.log('done')`))).resolves.toBeUndefined() -}) +}, integrationTimeout) test('timeout terminates its owned process and preserves its specific reason', async () => { const options = fixture(`console.error('install started'); setInterval(() => {}, 1000)`) await expect(runOfficialCommand({ ...options, timeoutMs: 150 })).rejects.toThrow('插件依赖更新超时') expect(readFileSync(join(options.logDirectory, 'plugin-operation-failure.log'), 'utf8')).toContain('install started') -}) +}, integrationTimeout) test('retains the owned process handle if timeout cleanup cannot confirm it stopped', async () => { vi.spyOn(owned, 'stopOwnedProcess').mockRejectedValue(new Error('not stopped')) @@ -46,5 +47,5 @@ test('retains the owned process handle if timeout cleanup cannot confirm it stop const child = tracked if (child) await new Promise(resolve => { child.once('close', resolve); child.kill('SIGKILL') }) } - await vi.waitFor(() => expect(tracked).toBeUndefined()) -}) + await vi.waitFor(() => expect(tracked).toBeUndefined(), { timeout: process.platform === 'win32' ? 15_000 : 1_000 }) +}, integrationTimeout) diff --git a/apps/desktop/tests/owned-process-windows.spec.ts b/apps/desktop/tests/owned-process-windows.spec.ts index a5ed97a307..e853d759a6 100644 --- a/apps/desktop/tests/owned-process-windows.spec.ts +++ b/apps/desktop/tests/owned-process-windows.spec.ts @@ -1,23 +1,127 @@ import { EventEmitter } from 'node:events' import { type ChildProcess, spawn } from 'node:child_process' -import { expect, it, vi } from 'vitest' -import { stopOwnedProcess } from '../src/owned-process.ts' +import { afterEach, expect, it, vi } from 'vitest' +import { cleanOwnedProcessTree, stopOwnedProcess, trackOwnedProcess, selectOwnedWindowsProcesses, type WindowsProcessIdentity } from '../src/owned-process.ts' vi.mock('node:child_process', () => ({ spawn: vi.fn() })) - -for (const exitCode of [0, 1]) it(`Windows tree cleanup ${exitCode === 0 ? 'confirms success' : 'rejects failure'}`, async () => { - const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')! +const platform = Object.getOwnPropertyDescriptor(process, 'platform')! +afterEach(() => { Object.defineProperty(process, 'platform', platform); vi.restoreAllMocks() }) +function fixture(rows: WindowsProcessIdentity[], options: { exited?: boolean, taskkillCode?: number, queryFails?: boolean, osPresent?: boolean } = {}) { Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }) - const child = { pid: 424242, kill: vi.fn() } as unknown as ChildProcess - vi.mocked(spawn).mockImplementation(() => { - const task = new EventEmitter() - queueMicrotask(() => task.emit('exit', exitCode)) - return task as ChildProcess + vi.spyOn(Date, 'now').mockReturnValue(1000) + const missing = Object.assign(new Error('missing'), { code: 'ESRCH' }) + vi.spyOn(process, 'kill').mockImplementation(() => { if (options.osPresent) return true; throw missing }) + const child = Object.assign(new EventEmitter(), { pid: 424242, kill: vi.fn(), exitCode: options.exited ? 0 : null, signalCode: null }) as unknown as ChildProcess + trackOwnedProcess(child, 900) + if (options.exited) child.emit('exit', 0) + const killed: number[] = [], scripts: string[] = [] + vi.mocked(spawn).mockImplementation((_command, args) => { + const script = Buffer.from(String(args![args!.length - 1]), 'base64').toString('utf16le'); scripts.push(script) + const task = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter(), kill: vi.fn() }) + queueMicrotask(() => { + if (script.includes('/PID')) { + const pid = Number(script.match(/\/PID (\d+)/u)![1]); killed.push(pid) + const code = options.taskkillCode ?? 0 + if (code === 0) rows = rows.filter(row => row.pid !== pid) + task.stdout.emit('data', Buffer.from(JSON.stringify({ status: 'taskkill', code }))) + task.emit('close', 0) + } else if (options.queryFails) { + task.stderr.emit('data', Buffer.from('Access denied')); task.emit('close', 1) + } else { + task.stdout.emit('data', Buffer.from(JSON.stringify(rows))); task.emit('close', 0) + } + }) + return task as unknown as ChildProcess }) - try { - if (exitCode === 0) await expect(stopOwnedProcess(child)).resolves.toBeUndefined() - else await expect(stopOwnedProcess(child)).rejects.toThrow('清理未确认') - expect(spawn).toHaveBeenLastCalledWith('taskkill', ['/PID', '424242', '/T', '/F'], { windowsHide: true, stdio: 'ignore' }) - expect(child.kill).not.toHaveBeenCalled() - } finally { Object.defineProperty(process, 'platform', descriptor) } + return { child, killed, scripts } +} + +it('confirms an already absent root only after CIM finds no owned descendants and OS reports ESRCH', async () => { + const { child, killed } = fixture([], { exited: true }) + await expect(cleanOwnedProcessTree(child)).resolves.toBeUndefined() + expect(killed).toEqual([]) + expect(process.kill).toHaveBeenCalledWith(424242, 0) +}) + +it('cleans living descendants even when their owned root has already exited', async () => { + const { child, killed, scripts } = fixture([ + { pid: 424243, ppid: 424242, createdAt: 950 }, + { pid: 424244, ppid: 424243, createdAt: 980 }, + ], { exited: true }) + await expect(stopOwnedProcess(child)).resolves.toBeUndefined() + expect(killed).toEqual([424244, 424243]) + expect(scripts.filter(script => script.includes('/PID')).every(script => script.includes('CreationDate') && script.includes('-ne'))).toBe(true) + expect(child.kill).not.toHaveBeenCalled() +}) + +it('preserves real taskkill permission failures instead of treating every nonzero exit as an absent PID', async () => { + const { child, killed } = fixture([{ pid: 424242, ppid: 1, createdAt: 950 }], { taskkillCode: 1, osPresent: true }) + await expect(stopOwnedProcess(child)).rejects.toThrow('清理失败 (1)') + expect(killed).toEqual([424242]) +}) + +it('does not accept not-found status when the operating system still sees the owned process', async () => { + const { child } = fixture([{ pid: 424242, ppid: 1, createdAt: 950 }], { taskkillCode: 128, osPresent: true }) + await expect(stopOwnedProcess(child)).rejects.toThrow('清理失败 (128)') +}) + +it('skips a reused root PID and its new children without signalling or killing the new generation', async () => { + const { child, killed } = fixture([ + { pid: 424242, ppid: 1, createdAt: 1100 }, + { pid: 424243, ppid: 424242, createdAt: 1110 }, + ], { exited: true, osPresent: true }) + await expect(stopOwnedProcess(child)).resolves.toBeUndefined() + expect(killed).toEqual([]) + expect(process.kill).not.toHaveBeenCalled() }) + +it('fails closed when the public process inventory cannot be queried', async () => { + const { child, killed } = fixture([], { exited: true, queryFails: true }) + await expect(stopOwnedProcess(child)).rejects.toThrow('清理查询失败') + expect(killed).toEqual([]) +}) + +it('does not signal a Windows process without a carrier ownership record', async () => { + fixture([]) + const child = { pid: 999999 } as ChildProcess + await expect(stopOwnedProcess(child)).rejects.toThrow('缺少所有权记录') +}) + +it('keeps old orphaned descendants while excluding children created by a reused parent generation', () => { + const rows = [ + { pid: 42, ppid: 1, createdAt: 1200 }, + { pid: 43, ppid: 42, createdAt: 950 }, + { pid: 44, ppid: 42, createdAt: 1250 }, + ] + expect(selectOwnedWindowsProcesses(rows, { pid: 42, startedAt: 900, registeredAt: 1000, exitedAt: 1100 })).toEqual([rows[1]]) +}) + +it('does not claim a parent generation that predates the owned spawn window', () => { + expect(selectOwnedWindowsProcesses([{ pid: 42, ppid: 1, createdAt: 800 }, { pid: 43, ppid: 42, createdAt: 950 }], { pid: 42, startedAt: 900, registeredAt: 1000, exitedAt: 1100 })).toEqual([]) +}) + +it.skipIf(platform.value !== 'win32')('reclaims a real Windows tool after its tracked parent has exited', async () => { + const actual = await vi.importActual('node:child_process') + vi.mocked(spawn).mockImplementation(actual.spawn) + const fixture = `const {spawn}=require('node:child_process'); +const tool=spawn(process.execPath,['-e',"console.log(process.pid);setInterval(()=>{},1000);setTimeout(()=>process.exit(0),30000)"],{stdio:['ignore','pipe','inherit']}); +tool.stdout.once('data',data=>{process.stdout.write(data,()=>process.exit(0))});` + const startedAt = Date.now() + const parent = spawn(process.execPath, ['-e', fixture], { stdio: ['ignore', 'pipe', 'pipe'] }) + trackOwnedProcess(parent, startedAt) + const exited = new Promise(resolve => parent.once('exit', () => resolve())) + let descendant = 0 + try { + descendant = await new Promise((resolve, reject) => { parent.stdout!.once('data', chunk => resolve(Number(String(chunk).trim()))); parent.once('error', reject) }) + expect(descendant).toBeGreaterThan(0) + await exited + await cleanOwnedProcessTree(parent) + await vi.waitFor(() => expect(() => process.kill(descendant, 0)).toThrow(), { timeout: 3000 }) + await cleanOwnedProcessTree(parent) + } finally { + // Retry only through the generation-checked owner. The synthetic tool also + // has a 30-second deadline if Windows refuses cleanup, without targeting + // an unrelated process based on a possibly reused naked PID. + await cleanOwnedProcessTree(parent) + } +}, 20_000) diff --git a/apps/desktop/tests/owned-process.spec.ts b/apps/desktop/tests/owned-process.spec.ts index 6d4b2c6f9d..b9cb0b91c4 100644 --- a/apps/desktop/tests/owned-process.spec.ts +++ b/apps/desktop/tests/owned-process.spec.ts @@ -1,13 +1,15 @@ import { spawn } from 'node:child_process' import { once } from 'node:events' import { expect, it } from 'vitest' -import { stopOwnedProcess, cleanOwnedProcessTree } from '../src/owned-process.ts' +import { stopOwnedProcess, cleanOwnedProcessTree, trackOwnedProcess } from '../src/owned-process.ts' it.skipIf(process.platform === 'win32')('kills a real tool descendant that ignores SIGTERM even after its parent exits', async () => { const fixture = `const {spawn}=require('node:child_process'); const tool=spawn(process.execPath,['-e',"process.on('SIGTERM',()=>{}); console.log(process.pid);setInterval(()=>{},1000)"],{stdio:['ignore','pipe','inherit']}); tool.stdout.on('data',x=>process.stdout.write(x));process.on('SIGTERM',()=>process.exit(0));setInterval(()=>{},1000);` + const startedAt = Date.now() const parent = spawn(process.execPath, ['-e', fixture], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] }) + trackOwnedProcess(parent, startedAt) let descendant = 0 try { const [data] = await once(parent.stdout!, 'data') diff --git a/packages/plugins/workbenches/scripts/check-startup.mjs b/packages/plugins/workbenches/scripts/check-startup.mjs index 34ec12ad13..3b4141c579 100644 --- a/packages/plugins/workbenches/scripts/check-startup.mjs +++ b/packages/plugins/workbenches/scripts/check-startup.mjs @@ -7,6 +7,19 @@ import {fileURLToPath,pathToFileURL} from 'node:url'; import {spawn} from 'node:child_process'; import {isDeepStrictEqual} from 'node:util'; +// The platform writes this development-only record from its actual Profile +// context. It supplies host peers, never missing business dependencies or a +// replacement resolver for the candidate's executable JavaScript. +async function hostPeerRequire(directory){ + let text;try{text=await readFile(join(directory,'.workdsh-runtime.json'),'utf8');} + catch(error){if(error.code==='ENOENT')return undefined;throw Error('workbench/startup-invalid-runtime-anchor');} + try{ + const record=JSON.parse(text); + if(record.schemaVersion!==1||typeof record.installationAnchor!=='string'||!isAbsolute(record.installationAnchor))throw Error(); + return createRequire(await realpath(record.installationAnchor)); + }catch{throw Error('workbench/startup-invalid-runtime-anchor');} +} + /** Run from the official development tool's sandbox, never from package preview * or the live Host. A temporary Home is data separation, not an OS sandbox. * Test providers are explicitly reported and cannot certify production startup. */ @@ -35,12 +48,20 @@ async function worker(directory,home){ let ctx,stage='dependencies';const providers=['workdshWorkbenchRegistration (test adapter)']; try{ const pkg=JSON.parse(await readFile(join(directory,'package.json'),'utf8')),require=createRequire(join(directory,'package.json')); - const missing=[],mismatches=[]; + const hostRequire=await hostPeerRequire(directory),hostPeers=new Set(['@deepseek-ai/cordis','workdsh-plugin-workbenches']); + const missing=[],mismatches=[],resolvers=new Map(); const semver=createRequire(import.meta.url)('semver'); for(const [name,range] of Object.entries({...pkg.dependencies,...pkg.peerDependencies})){ - let entry;try{entry=require.resolve(name);}catch{if(pkg.peerDependenciesMeta?.[name]?.optional!==true||Object.hasOwn(pkg.dependencies??{},name))missing.push(name);continue;} + let entry,resolver=require; + try{entry=resolver.resolve(name);}catch{ + if(hostRequire&&hostPeers.has(name)&&Object.hasOwn(pkg.peerDependencies??{},name)&&!Object.hasOwn(pkg.dependencies??{},name)){ + resolver=hostRequire;try{entry=resolver.resolve(name);}catch{} + } + if(!entry){if(pkg.peerDependenciesMeta?.[name]?.optional!==true||Object.hasOwn(pkg.dependencies??{},name))missing.push(name);continue;} + } + resolvers.set(name,resolver); let manifest; - try{manifest=JSON.parse(await readFile(require.resolve(name+'/package.json'),'utf8'));}catch{ + try{manifest=JSON.parse(await readFile(resolver.resolve(name+'/package.json'),'utf8'));}catch{ let cursor=dirname(entry);for(let i=0;i<32;i++){ try{const value=JSON.parse(await readFile(join(cursor,'package.json'),'utf8'));if(value.name===name){manifest=value;break;}}catch{} const parent=dirname(cursor);if(parent===cursor)break;cursor=parent; @@ -55,9 +76,10 @@ async function worker(directory,home){ const path=await realpath(resolve(directory,entry)),delta=relative(directory,path); if(delta.startsWith('..')||isAbsolute(delta))throw Error('workbench/startup-invalid-entry'); const hostSha256=createHash('sha256').update(await readFile(path)).digest('hex'); - const cordisVersion=JSON.parse(await readFile(require.resolve('@deepseek-ai/cordis/package.json'),'utf8')).version; + const cordisRequire=resolvers.get('@deepseek-ai/cordis')??require; + const cordisVersion=JSON.parse(await readFile(cordisRequire.resolve('@deepseek-ai/cordis/package.json'),'utf8')).version; if(cordisVersion!=='4.0.5')throw Error('workbench/startup-unsupported-cordis'); - const {Context}=await import(pathToFileURL(require.resolve('@deepseek-ai/cordis')).href); + const {Context}=await import(pathToFileURL(cordisRequire.resolve('@deepseek-ai/cordis')).href); ctx=new Context();let registered=false; ctx.provide('workdshWorkbenchRegistration',{async registerManaged(caller,declaration,page){ if(!isDeepStrictEqual(declaration,pkg.workdsh?.workbench)||typeof page!=='string'||!page)throw Error('workbench/startup-registration-mismatch'); diff --git a/packages/plugins/workbenches/scripts/run-delivery.mjs b/packages/plugins/workbenches/scripts/run-delivery.mjs index 14e7d2cd62..cc2daa3789 100644 --- a/packages/plugins/workbenches/scripts/run-delivery.mjs +++ b/packages/plugins/workbenches/scripts/run-delivery.mjs @@ -6,8 +6,28 @@ import {fileURLToPath} from 'node:url'; import {createRequire} from 'node:module'; import {createHash} from 'node:crypto'; import {performance} from 'node:perf_hooks'; +import {gunzipSync} from 'node:zlib'; import {checkWorkbenchStartup} from './check-startup.mjs'; +// A development anchor contains machine-local paths, not distributable app +// data. Reject a legacy package without a files allowlist rather than editing +// its manifest/ignore rules or publishing that private platform record. +function verifyNoRuntimeRecord(bytes){ + if(bytes.length>8*1024*1024)throw Error('workbench/delivery-archive-size-limit'); + let tar;try{tar=gunzipSync(bytes,{maxOutputLength:64*1024*1024});}catch{throw Error('workbench/delivery-invalid-pack-result');} + for(let offset=0;offset+512<=tar.length;){ + const header=tar.subarray(offset,offset+512);if(header.every(byte=>byte===0))return; + const field=(start,end)=>header.subarray(start,end).toString('utf8').split('\0')[0]; + const name=[field(345,500),field(0,100)].filter(Boolean).join('/'),sizeText=field(124,136).trim(); + if(!/^[0-7]+$/.test(sizeText))throw Error('workbench/delivery-invalid-pack-result'); + const size=Number.parseInt(sizeText,8);if(!Number.isSafeInteger(size)||offset+512+size>tar.length)throw Error('workbench/delivery-invalid-pack-result'); + const extended=['x','g','L'].includes(field(156,157))?tar.subarray(offset+512,offset+512+size).toString('utf8'):''; + if(name.split('/').includes('.workdsh-runtime.json')||/(?:^|[=/])\.workdsh-runtime\.json(?:\0|\n|$)/.test(extended))throw Error('workbench/delivery-runtime-record-in-archive'); + offset+=512+Math.ceil(size/512)*512; + } + throw Error('workbench/delivery-invalid-pack-result'); +} + /** Resolve installed pnpm JavaScript, never a system npm or a .cmd wrapper. */ export async function resolveWorkbenchPackageManager(directory,{environment=process.env,nodeExecutable=process.execPath}={}){ const inspect=async(entry,expectedVersion)=>{ @@ -91,6 +111,7 @@ export async function runWorkbenchDelivery(directory,{signal,commandTimeoutMs=12 await command(['--config.ignore-scripts=true','--config.offline=true','pack','--pack-destination',packDirectory]); const packed=await readdir(packDirectory),filename=packed[0];if(packed.length!==1||typeof filename!=='string'||basename(filename)!==filename||!filename.endsWith('.tgz'))throw Error('workbench/delivery-invalid-pack-result'); const bytes=await readFile(join(packDirectory,filename)); + verifyNoRuntimeRecord(bytes); // Never silently replace an immutable version with different bytes. const destination=join(source,filename);try{await writeFile(destination,bytes,{flag:'wx',mode:0o600});}catch(error){if(error?.code==='EEXIST')throw Error('workbench/delivery-archive-already-exists');throw error;} report.candidate={filename,sha256:createHash('sha256').update(bytes).digest('hex'),bytes:bytes.length}; diff --git a/packages/plugins/workbenches/src/client/WorkbenchCreationPage.tsx b/packages/plugins/workbenches/src/client/WorkbenchCreationPage.tsx index 219600220a..fcebddb0d7 100644 --- a/packages/plugins/workbenches/src/client/WorkbenchCreationPage.tsx +++ b/packages/plugins/workbenches/src/client/WorkbenchCreationPage.tsx @@ -5,29 +5,45 @@ import {WorkbenchRequirementsForm} from './WorkbenchRequirementsForm.js'; import type {WorkbenchNativeAuthoringEditorProps} from './WorkbenchNativeAuthoringEditor.js'; import {createWorkbenchRequirementsClient} from './requirements-client.js'; import {createWorkbenchDevelopmentInstruction} from './development-instruction.js'; +import {creationProposalReview,restoreCreationReview,type WorkbenchCreationReview} from './creation-review.js'; import type {WorkbenchDevelopmentIndexEntry,WorkbenchRequirementsDraft} from 'workdsh-contracts'; export function WorkbenchCreationPage(props:{identityScopeKey:string;native:WorkbenchNativeAuthoringEditorProps['native'];initialCreation?:CreationView;onCancel:()=>void;onDevelopmentReady:(id:string)=>void}){ const client=useMemo(()=>createWorkbenchCreationClient(),[]); + const requirementsClient=useMemo(()=>createWorkbenchRequirementsClient(),[]); const [idea,setIdea]=useState(props.initialCreation?.idea??'');const [id,setId]=useState(()=>crypto.randomUUID()); const [creation,setCreation]=useState(props.initialCreation);const [history,setHistory]=useState([]); const [development,setDevelopment]=useState(); - const [review,setReview]=useState<{revision:string;draft:WorkbenchRequirementsDraft}|undefined>(props.initialCreation?.proposal?{revision:props.initialCreation.revision,draft:complete(props.initialCreation)}:undefined); + const [review,setReview]=useState(()=>props.initialCreation?creationProposalReview(props.initialCreation):undefined); const edited=useRef(undefined); + const reviewChoice=useRef(0); + const [recovery,setRecovery]=useState<{id:string;state:'pending'|'ready'|'failed'}|undefined>(()=>props.initialCreation?.phase==='discussing'?{id:props.initialCreation.id,state:'pending'}:undefined); + const [recoveryAttempt,setRecoveryAttempt]=useState(0); const [busy,setBusy]=useState(false),[error,setError]=useState(),[shown,setShown]=useState(true); const [opening,setOpening]=useState<{id:string;instruction:string;submit?:boolean}>(); const [openingIssued,setOpeningIssued]=useState(false); const [developmentIssued,setDevelopmentIssued]=useState(false); const request=useRef(undefined);const current=useRef(creation);current.current=creation; useEffect(()=>{const controller=new AbortController();void client.list(controller.signal).then(rows=>{if(!controller.signal.aborted)setHistory(rows.filter(row=>row.phase==='discussing'));}).catch(()=>{});return()=>{controller.abort();request.current?.abort();};},[client]); + useEffect(()=>{ + const row=current.current;if(!row||row.phase!=='discussing')return; + const controller=new AbortController(),choice=reviewChoice.current; + setRecovery({id:row.id,state:'pending'}); + void requirementsClient.history(row.id,controller.signal).then(rows=>{ + if(controller.signal.aborted||current.current?.id!==row.id||current.current.phase!=='discussing')return; + setReview(existing=>restoreCreationReview(row,rows,existing,!!edited.current||reviewChoice.current!==choice)); + setRecovery({id:row.id,state:'ready'}); + }).catch(()=>{if(!controller.signal.aborted&¤t.current?.id===row.id)setRecovery({id:row.id,state:'failed'});}); + return()=>controller.abort(); + },[creation?.id,props.identityScopeKey,requirementsClient,recoveryAttempt]); async function start(requestId=id,input=idea){ request.current?.abort();const controller=new AbortController();request.current=controller; setBusy(true);setError(undefined); try{ const result=await client.open(requestId,input,controller.signal);controller.signal.throwIfAborted(); if(result.phase==='confirmed'){props.onDevelopmentReady(result.id);return;} + if(current.current?.id!==result.id){edited.current=undefined;reviewChoice.current++;setReview(creationProposalReview(result));setRecovery({id:result.id,state:'pending'});} setCreation(result);setId(result.id);setIdea(result.idea); - if(result.proposal)setReview({revision:result.revision,draft:complete(result)}); if(result.needsOpening)setOpening({id:`creation-opening:${result.id}`,instruction:creationOpeningDraft(result),submit:true}); }catch(cause){if(!controller.signal.aborted)setError('暂时无法开始,想法已保留。请重试;不会重复创建应用。');} finally{if(request.current===controller){request.current=undefined;if(!controller.signal.aborted)setBusy(false);}} @@ -36,7 +52,7 @@ export function WorkbenchCreationPage(props:{identityScopeKey:string;native:Work const before=current.current;if(!before||signal.aborted)return; const rows=await client.list(signal);signal.throwIfAborted(); if(current.current?.id!==before.id)return; - const updated=rows.find(row=>row.id===before.id);if(updated?.phase==='confirmed'&&!development){props.onDevelopmentReady(updated.id);return;}if(updated){setCreation(updated);if(updated.proposal)setReview(existing=>existing??{revision:updated.revision,draft:complete(updated)});} + const updated=rows.find(row=>row.id===before.id);if(updated?.phase==='confirmed'&&!development){props.onDevelopmentReady(updated.id);return;}if(updated){setCreation(updated);if(updated.proposal)setReview(existing=>existing??creationProposalReview(updated));} } async function prepareDevelopment(entry:WorkbenchDevelopmentIndexEntry,signal?:AbortSignal){ const saved=(await createWorkbenchRequirementsClient().history(entry.development.id,signal)).at(-1);signal?.throwIfAborted(); @@ -48,7 +64,6 @@ export function WorkbenchCreationPage(props:{identityScopeKey:string;native:Work try{await prepareDevelopment(entry,controller.signal);}catch{if(!controller.signal.aborted)setError('开发草稿暂时无法准备,请从开发记录继续。');} finally{if(request.current===controller)request.current=undefined;} } - function complete(row:CreationView):WorkbenchRequirementsDraft{return {title:'',scenario:'',users:'',dataSources:'',workflow:'',firstIteration:'',acceptance:'',...row.proposal};} return

{creation?'一起完善你的应用':'你想做一个什么工作台?'}

{creation&&}
{!creation?<> @@ -65,9 +80,9 @@ export function WorkbenchCreationPage(props:{identityScopeKey:string;native:Work onDraftError={()=>{setOpening(undefined);setError('原生对话已有输入或暂时不可写,已保留原内容。请在原生会话中检查已有草稿及发送结果;不会自动重复发送。');}} onToolSettled={event=>{void refresh(event.signal).catch(()=>{if(!event.signal.aborted)setError('需求摘要读取未完成,可以刷新重试。');});}} onError={()=>setError('原生对话暂时无法打开,请重试。')}/> {development?(!developmentIssued&&):!openingIssued&&!review?:null} - {development?

需求已确认,开发工程已准备

{developmentIssued?'开发指令已交给原生会话处理,发送结果与执行状态以原生会话为准。':'需求已保存,开发指令尚未确认提交。平台会保留已有输入,并提示需要处理的冲突。'}

:review?<> - {review.revision!==creation.revision&&

AI 有新的摘要,当前修改已保留。

} - {edited.current=draft;}} onCancel={props.onCancel} onSave={async requirements=>{ + {development?

需求已确认,开发工程已准备

{developmentIssued?'开发指令已交给原生会话处理,发送结果与执行状态以原生会话为准。':'需求已保存,开发指令尚未确认提交。平台会保留已有输入,并提示需要处理的冲突。'}

:recovery?.id===creation.id&&recovery.state!=='ready'?

{recovery.state==='pending'?'正在读取已保存需求…':'暂时无法读取已保存需求。为保留上次修改,请读取成功后再确认。'}

{recovery.state==='failed'&&}
:review?<> + {review.revision!==creation.revision&&

AI 有新的摘要,当前修改已保留。

} + {edited.current=draft;}} onCancel={props.onCancel} onSave={async requirements=>{ request.current?.abort();const controller=new AbortController();request.current=controller; try{const result=await client.confirm(creation.id,review.revision,requirements,controller.signal);controller.signal.throwIfAborted();setCreation(result.creation);setDevelopment(result.development); try{await prepareDevelopment(result.development,controller.signal);}catch{if(!controller.signal.aborted)setError('需求已保存,但开发尚未提交。可点击“开始开发”重试;已有输入会保留。');} diff --git a/packages/plugins/workbenches/src/client/WorkbenchRequirementsForm.tsx b/packages/plugins/workbenches/src/client/WorkbenchRequirementsForm.tsx index 25f8793ca3..be1c3e432d 100644 --- a/packages/plugins/workbenches/src/client/WorkbenchRequirementsForm.tsx +++ b/packages/plugins/workbenches/src/client/WorkbenchRequirementsForm.tsx @@ -1,6 +1,7 @@ import React, { useRef, useState } from 'react'; import { WorkbenchSystemEditor } from './WorkbenchSystemEditor.js'; import { parseWorkbenchRequirementsDraft, type WorkbenchRequirementsDraft } from 'workdsh-contracts'; +import {requirementsSaveFailureMessage} from './requirements-save-error.js'; export interface WorkbenchRequirementsFormProps { readonly initial?: WorkbenchRequirementsDraft; /** Host persists the requirement revision before starting development or creating a session. */ @@ -42,7 +43,7 @@ export function WorkbenchRequirementsForm(props: WorkbenchRequirementsFormProps) catch { setError('请补齐需求、模块及页面名称,检查依赖和长度;尚不明确的数据和规则可注明“待验证”。'); return; } pending.current = true; setSaving(true); setError(undefined); try { await props.onSave(normalized); } - catch { setError('需求未保存成功,内容已保留,请核对后重试。'); } + catch(cause) { setError(requirementsSaveFailureMessage(cause)); } finally { pending.current = false; setSaving(false); } } return
void submit(event)} aria-label="定义工作台需求" style={{ maxWidth: 800, margin: '0 auto', padding: 32 }}> diff --git a/packages/plugins/workbenches/src/client/creation-client.ts b/packages/plugins/workbenches/src/client/creation-client.ts index 6c2af326bb..4a7c10d58a 100644 --- a/packages/plugins/workbenches/src/client/creation-client.ts +++ b/packages/plugins/workbenches/src/client/creation-client.ts @@ -1,10 +1,16 @@ import type {CreationView,CreationProposal} from '../runtime/creation-service.js'; import type {WorkbenchRequirementsDraft,WorkbenchDevelopmentIndexEntry} from 'workdsh-contracts'; +export class WorkbenchCreationRequestError extends Error { + constructor(readonly code:string,readonly requirementsSaved=false,readonly preparationStage?:'development'|'source'|'session'){ + super(code);this.name='WorkbenchCreationRequestError'; + } +} export function createWorkbenchCreationClient(){ async function call(endpoint:string,payload:unknown,signal?:AbortSignal):Promise{ const response=await fetch('/api/workdsh-workbench-creation',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({endpoint,payload}),signal}); - const result=await response.json() as {ok:boolean;value:T;error?:{code?:string}};signal?.throwIfAborted(); - if(!response.ok||!result.ok)throw Error(result.error?.code??'workbench/creation-failed');return result.value; + const result=await response.json() as {ok:boolean;value:T;error?:{code?:string;requirementsSaved?:boolean;preparationStage?:string}};signal?.throwIfAborted(); + if(!response.ok||!result.ok){const stage=result.error?.preparationStage;throw new WorkbenchCreationRequestError(result.error?.code??'workbench/creation-failed',result.error?.requirementsSaved===true, + stage==='development'||stage==='source'||stage==='session'?stage:undefined);}return result.value; } return { list:(signal?:AbortSignal)=>call('list',{},signal), diff --git a/packages/plugins/workbenches/src/client/creation-review.ts b/packages/plugins/workbenches/src/client/creation-review.ts new file mode 100644 index 0000000000..b1cc38cfbb --- /dev/null +++ b/packages/plugins/workbenches/src/client/creation-review.ts @@ -0,0 +1,24 @@ +import type {WorkbenchRequirementsDraft,WorkbenchRequirementsRevision} from 'workdsh-contracts'; +import type {CreationView} from './creation-client.js'; + +export interface WorkbenchCreationReview { + readonly revision:string; + /** A persisted draft can change without changing the optimistic creation revision. */ + readonly seed:string; + readonly draft:WorkbenchRequirementsDraft; +} +export function creationProposalReview(creation:CreationView):WorkbenchCreationReview|undefined { + if(!creation.proposal)return; + return {revision:creation.revision,seed:`proposal:${creation.revision}`,draft:{title:'',scenario:'',users:'',dataSources:'',workflow:'',firstIteration:'',acceptance:'',...creation.proposal}}; +} +/** History has already passed the requirements client's ownership/chain validation. + * Reading it never appends a revision or advances the creation conflict token. */ +export function restoreCreationReview(creation:CreationView,history:readonly WorkbenchRequirementsRevision[],current?:WorkbenchCreationReview,preserveCurrent=false):WorkbenchCreationReview|undefined { + if(preserveCurrent)return current; + const saved=history.at(-1); + if(saved){ + if(saved.developmentId!==creation.id)throw Error('workbench/requirements-revision-conflict'); + return {revision:creation.revision,seed:`requirements:${saved.id}`,draft:saved.requirements}; + } + return current??creationProposalReview(creation); +} diff --git a/packages/plugins/workbenches/src/client/requirements-save-error.ts b/packages/plugins/workbenches/src/client/requirements-save-error.ts new file mode 100644 index 0000000000..9d389c944b --- /dev/null +++ b/packages/plugins/workbenches/src/client/requirements-save-error.ts @@ -0,0 +1,12 @@ +/** Display only owned status fields; never render raw Host exception paths. */ +export function requirementsSaveFailureMessage(cause:unknown){ + const error=cause&&typeof cause==='object'?cause as {code?:unknown;requirementsSaved?:unknown;preparationStage?:unknown}:undefined; + if(error?.requirementsSaved===true){ + if(error.code==='workbench/sdk-package-unavailable')return '需求已保存,但平台开发 SDK 无法读取,开发工程尚未准备完成。请核对平台安装后重试;需求和原有会话会保留。'; + if(error.preparationStage==='source')return '需求已保存,但开发源码准备失败。请保留当前内容,修复后重试;不会重复创建应用。'; + if(error.preparationStage==='session')return '需求已保存,但原生开发会话尚未关联。请保留当前内容,核对后重试。'; + return '需求已保存,但开发记录尚未准备完成。请保留当前内容,核对后重试。'; + } + if(error?.code==='workbench/creation-conflict')return '需求摘要或已保存内容已经变化。当前编辑仍保留,请核对最新摘要后继续。'; + return '需求保存或开发准备尚未确认完成,内容已保留,请核对开发记录后重试。'; +} diff --git a/packages/plugins/workbenches/src/runtime/creation-development.ts b/packages/plugins/workbenches/src/runtime/creation-development.ts new file mode 100644 index 0000000000..656352c0d6 --- /dev/null +++ b/packages/plugins/workbenches/src/runtime/creation-development.ts @@ -0,0 +1,29 @@ +import type {WorkbenchRequirementsDraft} from 'workdsh-contracts'; +import type {CreationRecord} from './creation-service.js'; +import type {WorkbenchRequirementsService} from './requirements-service.js'; +import type {WorkbenchDevelopmentService,VerifyWorkbenchDevelopmentSource,VerifyWorkbenchDevelopmentSession} from './development-service.js'; + +/** Requirements have already committed. Preserve the safe underlying code and + * the failed continuation stage without exposing arbitrary exception text. */ +export class WorkbenchCreationPreparationError extends Error { + readonly requirementsSaved=true; + constructor(readonly preparationStage:'development'|'source'|'session',cause:unknown){ + super(cause instanceof Error&&/^workbench\/[a-z0-9-]+$/.test(cause.message)?cause.message:'workbench/creation-development-preparation-failed',{cause}); + this.name='WorkbenchCreationPreparationError'; + } +} +export async function confirmCreationDevelopment(row:CreationRecord,requirements:WorkbenchRequirementsDraft, + ports:{requirements:Pick;development:Pick;source:VerifyWorkbenchDevelopmentSource;session:VerifyWorkbenchDevelopmentSession}, + check:()=>void,signal?:AbortSignal){ + check();let saved=(await ports.requirements.history(row.id,signal)).at(-1);check(); + if(saved&&JSON.stringify(saved.requirements)!==JSON.stringify(requirements))throw Error('workbench/creation-conflict'); + if(!saved){saved=await ports.requirements.append(row.id,requirements,undefined,signal);check();} + let stage:'development'|'source'|'session'='development'; + try{ + let entry=await ports.development.create(row.id,`workdsh.user-${row.id}`,`workdsh-workbench-${row.id}`,saved.id,signal);check(); + if(row.phase==='confirmed')return entry; + stage='source';entry=await ports.development.bindVerifiedSource(row.id,entry.development.revision,ports.source,signal);check(); + stage='session';entry=await ports.development.bindVerifiedSession(row.id,entry.development.revision,ports.session,signal);check(); + return entry; + }catch(cause){throw new WorkbenchCreationPreparationError(stage,cause);} +} diff --git a/packages/plugins/workbenches/src/runtime/creation-host.ts b/packages/plugins/workbenches/src/runtime/creation-host.ts index 77e4c8bdc3..f667e4ce12 100644 --- a/packages/plugins/workbenches/src/runtime/creation-host.ts +++ b/packages/plugins/workbenches/src/runtime/creation-host.ts @@ -15,6 +15,7 @@ import {createNativeWorkbenchDevelopmentSessionVerifier} from './native-developm import type {mountProfileWorkbenchAuthoringHost} from './profile-authoring-host.js'; import {handleWorkbenchCreationRequest} from './creation-rpc.js'; import {defineTool,type ToolRunContext} from '@deepseek-ai/dsh-tools'; +import {confirmCreationDevelopment} from './creation-development.js'; export const workbenchCreationPath='/api/workdsh-workbench-creation'; export async function mountWorkbenchCreationHost(ctx:Context,authoring:Awaited>){ @@ -66,15 +67,8 @@ export async function mountWorkbenchCreationHost(ctx:Context,authoring:Awaited()=>service.close(),'workdsh.creation.close'); diff --git a/packages/plugins/workbenches/src/runtime/creation-rpc.ts b/packages/plugins/workbenches/src/runtime/creation-rpc.ts index cca16409af..d4a9adf4e8 100644 --- a/packages/plugins/workbenches/src/runtime/creation-rpc.ts +++ b/packages/plugins/workbenches/src/runtime/creation-rpc.ts @@ -1,5 +1,6 @@ import type {WorkbenchCreationService} from './creation-service.js'; import {readWorkbenchRequestBody} from './request-body.js'; +import {WorkbenchCreationPreparationError} from './creation-development.js'; export async function handleWorkbenchCreationRequest(request:Request,service:Pick){ try{ @@ -16,6 +17,8 @@ export async function handleWorkbenchCreationRequest(request:Request,service:Pic const message=cause instanceof Error?cause.message:''; const code=/^workbench\/[a-z0-9-]+$/.test(message)?message:'workbench/creation-failed'; const status=request.signal.aborted?408:code==='workbench/creation-access-denied'?403:code==='workbench/creation-not-found'?404:code==='workbench/creation-conflict'?409:code.includes('invalid-')?400:503; - return Response.json({ok:false,error:{code,message:status===409?'内容已变化,请重新读取后继续。':'本次操作未完成,请保留当前内容重试。'}},{status}); + const saved=cause instanceof WorkbenchCreationPreparationError; + return Response.json({ok:false,error:{code,message:saved?'需求已保存,但开发准备尚未完成。请保留当前内容,修复后重试。':status===409?'内容已变化,请重新读取后继续。':'本次操作未完成,请保留当前内容重试。', + ...(saved?{requirementsSaved:true,preparationStage:cause.preparationStage}:{})}},{status}); } } diff --git a/packages/plugins/workbenches/src/runtime/development-starter.ts b/packages/plugins/workbenches/src/runtime/development-starter.ts index c0525095db..6d377ebd31 100644 --- a/packages/plugins/workbenches/src/runtime/development-starter.ts +++ b/packages/plugins/workbenches/src/runtime/development-starter.ts @@ -10,17 +10,19 @@ export function createWorkbenchStarter(packageName:string,descriptor:WorkbenchDe workdsh:{workbench:descriptor}, peerDependencies:{'@deepseek-ai/cordis':'4.0.5','workdsh-plugin-workbenches':'^'+sdkPackageVersion}, devDependencies:{esbuild:'0.25.12'}}; + const sdkLocation="import {readFile,realpath} from 'node:fs/promises';\nimport {createRequire} from 'node:module';\nimport {dirname,join,isAbsolute} from 'node:path';\nimport {fileURLToPath,pathToFileURL} from 'node:url';\nconst source=dirname(fileURLToPath(import.meta.url));\nlet anchor=join(source,'package.json');\nlet record;try{record=await readFile(join(source,'.workdsh-runtime.json'),'utf8');}catch(error){if(error.code!=='ENOENT')throw error;}\nif(record!==undefined){try{const runtime=JSON.parse(record);if(runtime?.schemaVersion!==1||typeof runtime.installationAnchor!=='string'||!isAbsolute(runtime.installationAnchor))throw Error();anchor=await realpath(runtime.installationAnchor);}catch{throw Error('workbench/development-runtime-invalid');}}\nconst sdk=createRequire(anchor);\n"; return {manifest,files:{ - 'deliver.mjs':"import {runWorkbenchDelivery} from 'workdsh-plugin-workbenches/delivery';\nconst result=await runWorkbenchDelivery(process.cwd());console.log(JSON.stringify(result,null,2));if(result.status!=='passed')process.exitCode=1;\n", - 'check-startup.mjs':"import {checkWorkbenchStartup} from 'workdsh-plugin-workbenches/startup-check';\nconst result=await checkWorkbenchStartup(process.cwd());console.log(JSON.stringify(result,null,2));if(result.status!=='passed')process.exitCode=1;\n", + 'deliver.mjs':sdkLocation+"const {runWorkbenchDelivery}=await import(pathToFileURL(sdk.resolve('workdsh-plugin-workbenches/delivery')).href);\nconst result=await runWorkbenchDelivery(source);console.log(JSON.stringify(result,null,2));if(result.status!=='passed')process.exitCode=1;\n", + 'check-startup.mjs':sdkLocation+"const {checkWorkbenchStartup}=await import(pathToFileURL(sdk.resolve('workdsh-plugin-workbenches/startup-check')).href);\nconst result=await checkWorkbenchStartup(source);console.log(JSON.stringify(result,null,2));if(result.status!=='passed')process.exitCode=1;\n", '.npmrc':'# Host runtime peers are verified on installation, not downloaded for this JS build.\nauto-install-peers=false\nstrict-peer-dependencies=false\nstore-dir=.workdsh-pnpm-store\n', - '.gitignore':'node_modules/\ndist/\n.workdsh-pnpm-store/\n*.tgz\n', + 'pnpm-workspace.yaml':'# pnpm 11 project policy; .npmrc retains compatibility with pnpm 10.\nautoInstallPeers: false\nstrictPeerDependencies: false\nstoreDir: .workdsh-pnpm-store\npmOnFail: ignore\nverifyDepsBeforeRun: false\n', + '.gitignore':'node_modules/\ndist/\n.workdsh-pnpm-store/\n.workdsh-runtime.json\n*.tgz\n', 'index.d.ts':"import type {Context} from '@deepseek-ai/cordis';\nimport type {WorkbenchDescriptor,WorkbenchSystemDefinition} from 'workdsh-plugin-workbenches';\nexport declare const inject: string[];\nexport declare const descriptor: WorkbenchDescriptor;\nexport declare function apply(ctx:Context):Promise;\n", 'client.d.ts':"import type {Context} from '@deepseek-ai/cordis';\nexport declare const inject: string[];\nexport declare function apply(ctx:Context):Promise;\n", 'index.js':`import manifest from './package.json';\nexport const inject=['workdshWorkbenchRegistration'];\nexport const descriptor=manifest.workdsh.workbench;\nexport async function apply(ctx){await ctx.get('workdshWorkbenchRegistration').registerManaged(ctx,descriptor,${JSON.stringify(packageName+'-page')});}\n`, 'client.jsx':`import React,{useState} from 'react';\nimport manifest from './package.json';\nexport const inject=['workdshWorkbenchClientRegistration'];\nconst descriptor=manifest.workdsh.workbench;\nconst modules=${modules};\nfunction Page(){const [module,setModule]=useState(modules[0][0]);return

{descriptor.title}

{modules.find(([id])=>id===module)?.[1]}

插件骨架已加载。本模块业务功能尚未实现,请根据确认的需求继续开发。

;}\nexport async function apply(ctx){await ctx.get('workdshWorkbenchClientRegistration').registerManaged(ctx,descriptor,Page);}\n`, 'build.mjs':`import {build} from 'esbuild';\nimport {mkdir,writeFile,readFile,copyFile} from 'node:fs/promises';\nawait mkdir('dist',{recursive:true});\nawait copyFile('index.d.ts','dist/index.d.ts');\nawait copyFile('client.d.ts','dist/client.d.ts');\nawait build({entryPoints:['index.js'],outfile:'dist/index.js',bundle:true,platform:'node',format:'esm'});\nconst manifest=JSON.parse(await readFile('package.json','utf8'));\nconst client=await build({entryPoints:['client.jsx'],bundle:true,write:false,platform:'browser',format:'cjs',target:'es2022',external:['react','react/jsx-runtime','react-dom','workdsh-plugin-workbenches/client']});\nawait writeFile('dist/client.browser.js','window.__ModuleLoader__.load({id:'+JSON.stringify(manifest.name)+',factory:function(require){const module={exports:{}};\\n'+client.outputFiles[0].text+'\\nreturn module.exports;}});\\n');\n`, 'cordis.patch.yml':`- insert:\n - id: ${JSON.stringify(packageName)}\n name: ${JSON.stringify(packageName)}\n`, - 'README.md':'# 工作台插件开发骨架\n\nHost 与 Client 从 package.json 的 workdsh.workbench 读取同一份注册声明;修改标题、说明或能力时修改该声明,重新构建,不另写一份声明。\n\n这是可构建的多模块入口,不是已完成的业务插件。先核对需求和实际安装平台版本,再实现数据、权限、持久化与模块内容。工作台应按可长期使用的软件组织:根据业务提供记录列表、详情与返回路径,通过应用内导航或页签切换任务,避免全部功能纵向堆叠;工具栏保持可达,内容有界滚动,列表随数量增长提供搜索和分页。多记录或一次性工具按已确认需求决定,不把硬编码演示对象当成整个软件。\n\n先通过官方工作区依赖工具取得当前 Node 与 pnpm JavaScript 入口,以 ` install` 安装开发工具,再执行 `node deliver.mjs`,平台统一构建、启动检查和打包并记录各阶段耗时;不要逐个应用编写交付脚本。项目 .npmrc 将 pnpm store 放在项目内,并跳过构建时自动下载宿主 peer;不会移除 package.json 的运行依赖声明,安装时仍由平台核对版本与来源。若要使用 TypeScript 或新增公开 SDK 导入,需另外取得实际平台 SDK 制品及所需类型依赖,不能把这个 JS 骨架当作完整 SDK 类型验收。先由平台检查 tgz 并试用,更新沿用包名和 workbenchId;不要自动替换已有插件或数据。不要复制官方内核代码,不使用 Puck。复用系统 Office 与公开领域服务。\n', + 'README.md':'# 工作台插件开发骨架\n\nHost 与 Client 从 package.json 的 workdsh.workbench 读取同一份注册声明;修改标题、说明或能力时修改该声明,重新构建,不另写一份声明。\n\n这是可构建的多模块入口,不是已完成的业务插件。先核对需求和实际安装平台版本,再实现数据、权限、持久化与模块内容。工作台应按可长期使用的软件组织:根据业务提供记录列表、详情与返回路径,通过应用内导航或页签切换任务,避免全部功能纵向堆叠;工具栏保持可达,内容有界滚动,列表随数量增长提供搜索和分页。多记录或一次性工具按已确认需求决定,不把硬编码演示对象当成整个软件。\n\n先通过官方工作区依赖工具取得当前 Node 与 pnpm JavaScript 入口,以 ` install` 安装开发工具,再执行 `node deliver.mjs`,平台统一构建、启动检查和打包并记录各阶段耗时;不要逐个应用编写交付脚本。项目 pnpm-workspace.yaml(并保留兼容旧版的 .npmrc)将 pnpm store 放在项目内,并跳过自动下载宿主 peer;平台另行维护 .workdsh-runtime.json 来定位当前宿主 SDK,不把安装目录写成业务依赖或带进插件包;不会移除 package.json 的运行依赖声明,安装时仍由平台核对版本与来源。若要使用 TypeScript 或新增公开 SDK 导入,需另外取得实际平台 SDK 制品及所需类型依赖,不能把这个 JS 骨架当作完整 SDK 类型验收。先由平台检查 tgz 并试用,更新沿用包名和 workbenchId;不要自动替换已有插件或数据。不要复制官方内核代码,不使用 Puck。复用系统 Office 与公开领域服务。\n', }}; } diff --git a/packages/plugins/workbenches/src/runtime/profile-development-source.ts b/packages/plugins/workbenches/src/runtime/profile-development-source.ts index 02d32ad614..4b8d9eec06 100644 --- a/packages/plugins/workbenches/src/runtime/profile-development-source.ts +++ b/packages/plugins/workbenches/src/runtime/profile-development-source.ts @@ -1,26 +1,40 @@ import {checkpointWorkbenchSource} from './source-checkpoint.js'; import type {WorkbenchRequirementsService} from './requirements-service.js'; -import { mkdir, realpath, readFile } from 'node:fs/promises'; +import { mkdir, realpath, readFile, lstat, writeFile, rename, rm } from 'node:fs/promises'; +import {randomUUID} from 'node:crypto'; import { join, basename } from 'node:path'; +import { createRequire } from 'node:module'; import type { Context } from '@deepseek-ai/cordis'; import { getDshRuntimeVersion, type ProfileContext } from '@deepseek-ai/dsh-app-boot'; import type { IdentityService } from 'workdsh-contracts'; import type { VerifyWorkbenchDevelopmentSource } from './development-service.js'; import { prepareWorkbenchDevelopmentSource } from './prepare-development-source.js'; +async function saveDevelopmentRuntime(directory:string,installationAnchor:string,authorize:()=>void){ + const target=join(directory,'.workdsh-runtime.json'),temporary=target+'.tmp-'+randomUUID(); + authorize(); + try{const entry=await lstat(target);if(!entry.isFile()||entry.isSymbolicLink())throw Error('workbench/source-directory-outside');} + catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;} + authorize(); + try{ + await writeFile(temporary,JSON.stringify({schemaVersion:1,installationAnchor},null,2)+'\n',{flag:'wx',mode:0o600}); + authorize();await rename(temporary,target);authorize(); + }finally{await rm(temporary,{force:true});} +} + /** Profile-local source ownership. A buildable entry scaffold is prepared for new directories; * the Agent must implement and validate executable plugin entries. */ export function createProfileWorkbenchDevelopmentSourceVerifier(ctx: Context): VerifyWorkbenchDevelopmentSource { const profile = ctx.get('profileContext') as ProfileContext | undefined; const identity = ctx.get('workdshIdentity') as IdentityService | undefined; if (!profile || !identity) throw new Error('workbench/host-services-unavailable'); - const captured = { name: profile.name, dir: profile.dir, home: profile.home }; + const captured = { name: profile.name, dir: profile.dir, home: profile.home, installAnchor: profile.installAnchor }; return async (actor, development, signal) => { const authorize = () => { signal?.throwIfAborted(); ctx.fiber.assertActive(); const current = ctx.get('profileContext') as ProfileContext | undefined; const member = identity.profile(); - if (!current || current.name !== captured.name || current.dir !== captured.dir || current.home !== captured.home) + if (!current || current.name !== captured.name || current.dir !== captured.dir || current.home !== captured.home || current.installAnchor !== captured.installAnchor) throw new Error('workbench/installation-target-changed'); if (member.principalId !== actor.principalId || member.organization.id !== actor.organizationId || member.resolvedBy !== actor.resolvedBy || identity.membership(actor.organizationId, actor.principalId)?.state !== 'active' || @@ -29,7 +43,15 @@ export function createProfileWorkbenchDevelopmentSourceVerifier(ctx: Context): V }; authorize(); const profileDirectory = await realpath(captured.dir); - const sdkManifest=JSON.parse(await readFile(join(profileDirectory,'node_modules/workdsh-plugin-workbenches/package.json'),'utf8')) as {name?:string;version?:string}; + // Desktop's writable Profile deliberately has no dependency tree. Resolve + // from the Host-owned installation graph; standalone Web Profiles may own + // their installation themselves. Never derive a package path from dir. + const installationAnchor=captured.installAnchor??join(profileDirectory,'package.json'); + let sdkManifest:{name?:string;version?:string}; + try { + const manifest=createRequire(installationAnchor).resolve('workdsh-plugin-workbenches/package.json'); + sdkManifest=JSON.parse(await readFile(manifest,'utf8')) as {name?:string;version?:string}; + } catch(cause) { throw new Error('workbench/sdk-package-unavailable',{cause}); } authorize(); if(sdkManifest.name!=='workdsh-plugin-workbenches'||typeof sdkManifest.version!=='string')throw Error('workbench/sdk-package-unavailable'); const root = join(profileDirectory, 'workdsh-workbench-sources'); @@ -49,6 +71,9 @@ export function createProfileWorkbenchDevelopmentSourceVerifier(ctx: Context): V if (development.source && (development.source.hostId !== development.target.hostId || development.source.directory !== prepared.directory)) throw new Error('workbench/source-identity-mismatch'); authorize(); + // Platform metadata is separate from developer source and excluded from the + // starter package's files allowlist. pnpm may rebuild node_modules freely. + await saveDevelopmentRuntime(prepared.directory,installationAnchor,authorize); const checkpoint=await checkpointWorkbenchSource(prepared.directory,join(profileDirectory,'workdsh-workbench-source-checkpoints',basename(prepared.directory)),authorize,signal); authorize(); return Object.freeze({ hostId: development.target.hostId, directory: prepared.directory, diff --git a/packages/plugins/workbenches/tests/creation-development.test.mjs b/packages/plugins/workbenches/tests/creation-development.test.mjs new file mode 100644 index 0000000000..fef6e708e0 --- /dev/null +++ b/packages/plugins/workbenches/tests/creation-development.test.mjs @@ -0,0 +1,43 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +const web=createRequire(new URL('../../../../apps/web/package.json',import.meta.url)); +const runtime=createRequire(new URL('../../projects/package.json',import.meta.url)); +const built=await web('esbuild').build({stdin:{contents:"export {confirmCreationDevelopment} from './creation-development.ts';export {WorkbenchCreationService} from './creation-service.ts';export {WorkbenchRequirementsService} from './requirements-service.ts';export {WorkbenchDevelopmentService} from './development-service.ts';export {restoreCreationReview} from '../client/creation-review.ts';",resolveDir:fileURLToPath(new URL('../src/runtime',import.meta.url))},bundle:true,platform:'node',format:'esm',write:false,alias:{'workdsh-contracts':fileURLToPath(new URL('../../../contracts/src/index.ts',import.meta.url))},plugins:[{name:'official',setup(builder){builder.onResolve({filter:/^(?:@deepseek-ai\/dsh-storage-domain|zod)$/},args=>({path:pathToFileURL(runtime.resolve(args.path)).href,external:true}));}}]}); +const {confirmCreationDevelopment,WorkbenchCreationService,WorkbenchRequirementsService,WorkbenchDevelopmentService,restoreCreationReview}=await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString('base64')}`); +const load=name=>import(pathToFileURL(runtime.resolve(name)).href); +const {Context}=await load('@deepseek-ai/cordis');const {default:Storage}=await load('@deepseek-ai/dsh-storage');const Json=await load('@deepseek-ai/dsh-storage-json');const Domain=await load('@deepseek-ai/dsh-storage-domain'); +test('confirmation resumes after saved requirements and project preparation failure without duplicate records or Session',async()=>{ + const root=await mkdtemp(join(tmpdir(),'workdsh-confirm-resume-')),ctx=new Context(); + await ctx.plugin(Storage);await ctx.plugin(Json,{root});await ctx.plugin(Domain,{backend:'json'}); + const actor={principalId:'local-user',organizationId:'local-personal',requestId:'test',resolvedBy:'trusted-fixture'}; + const identity={resolve:async()=>actor,profile:()=>({principalId:actor.principalId,organization:{id:actor.organizationId},resolvedBy:actor.resolvedBy}),membership:()=>({state:'active'})}; + const target={hostId:'host',installationId:'installation',profileId:'workdsh',harnessVersion:'0.2.1-rc.1'}; + const requirements=new WorkbenchRequirementsService(ctx.storageDomain,identity); + const development=new WorkbenchDevelopmentService(ctx.storageDomain,identity,requirements,async()=>target); + let sdkAvailable=false,sourceChecks=0,sessionChecks=0,sessionsCreated=0; + const ports={requirements,development,source:async()=>{sourceChecks++;if(!sdkAvailable)throw Error('workbench/sdk-package-unavailable');return {hostId:'host',directory:'/synthetic/owned/source',revision:'source-sha256:'+('a'.repeat(64))};},session:async()=>{sessionChecks++;return {hostId:'host',sessionId:'native-original-session'};}}; + const service=new WorkbenchCreationService(ctx.storageDomain,identity,{target:async()=>target,authorizeSession:async(_actor,id)=>assert.equal(id,'native-original-session'),session:async(_actor,row)=>{if(!row.sessionId)sessionsCreated++;return 'native-original-session';},confirm:async(_actor,row,draft,check,signal)=>confirmCreationDevelopment(row,draft,ports,check,signal)}); + const id='d22cc68e-4b3d-4c97-825d-2db6f3769bbe'; + const draft={title:'旅行管家·实测',scenario:'管理多次旅行',users:'个人',dataSources:'手动录入',workflow:'旅行列表、行程、费用、待办',firstIteration:'新增和独立管理多次旅行',acceptance:'关闭重开数据保留'}; + try{ + const creation=await service.open(id,'长期管理多次旅行'); + const proposed=await service.propose(creation.sessionId,creation.revision,{...draft,title:'AI建议标题'}); + await assert.rejects(service.confirm(id,proposed.revision,draft),error=>error.message==='workbench/sdk-package-unavailable'&&error.requirementsSaved===true&&error.preparationStage==='source'); + assert.equal((await requirements.history(id)).length,1); + const partial=(await development.list())[0];assert.equal(partial.title,'旅行管家·实测');assert.equal(partial.development.source,undefined);assert.equal(partial.development.session,undefined); + const reopened=(await service.list())[0];assert.equal(reopened.phase,'discussing');assert.equal(reopened.proposal.title,'AI建议标题'); + const recovered=restoreCreationReview(reopened,await requirements.history(id));assert.equal(recovered.draft.title,'旅行管家·实测');assert.equal(recovered.revision,proposed.revision); + sdkAvailable=true; + await assert.rejects(service.confirm(id,proposed.revision,{...draft,title:'AI建议标题'}),/workbench\/creation-conflict/); + const completed=await service.confirm(id,recovered.revision,recovered.draft); + assert.equal(completed.creation.phase,'confirmed');assert.equal(completed.development.development.session.sessionId,'native-original-session'); + const retried=await service.confirm(id,recovered.revision,recovered.draft); + assert.deepEqual(retried,completed);assert.equal((await requirements.history(id)).length,1);assert.equal((await development.list()).length,1); + assert.equal(sessionsCreated,1);assert.equal(sourceChecks,2);assert.equal(sessionChecks,1); + }finally{await service.close();await development.close();await requirements.close();await ctx.fiber.dispose();await rm(root,{recursive:true,force:true});} +}); diff --git a/packages/plugins/workbenches/tests/creation-review.test.mjs b/packages/plugins/workbenches/tests/creation-review.test.mjs new file mode 100644 index 0000000000..31bb377f93 --- /dev/null +++ b/packages/plugins/workbenches/tests/creation-review.test.mjs @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +const web=createRequire(new URL('../../../../apps/web/package.json',import.meta.url)); +const built=await web('esbuild').build({stdin:{contents:"export * from './creation-review.ts';export {createWorkbenchRequirementsClient} from './requirements-client.ts';",resolveDir:fileURLToPath(new URL('../src/client',import.meta.url))},bundle:true,platform:'node',format:'esm',write:false,alias:{'workdsh-contracts':fileURLToPath(new URL('../../../contracts/src/index.ts',import.meta.url))}}); +const {creationProposalReview,restoreCreationReview,createWorkbenchRequirementsClient}=await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString('base64')}`); +const draft={title:'旅行管家·实测',scenario:'管理多次旅行',users:'个人',dataSources:'手动录入',workflow:'旅行列表、行程、费用、待办',firstIteration:'新增和独立管理多次旅行',acceptance:'关闭重开数据保留'}; +const creation={id:'creation',revision:'creation-revision',idea:'管理多次旅行',phase:'discussing',proposal:{...draft,title:'旅行管家·实测:多次旅行管理工作台'}}; +const saved={id:'requirements-revision',developmentId:creation.id,owner:{organizationId:'personal',ownerPrincipalId:'member',scope:'personal'},createdBy:'member',createdAt:'2026-10-10T00:00:00.000Z',requirements:draft}; +test('reopening uses validated saved edits instead of the older AI proposal without writing history',async()=>{ + const requests=[]; + const client=createWorkbenchRequirementsClient(undefined,async(_url,init)=>{requests.push(JSON.parse(init.body));return Response.json({ok:true,value:[saved]});}); + const proposal=creationProposalReview(creation),recovered=restoreCreationReview(creation,await client.history(creation.id),proposal); + assert.equal(proposal.draft.title,creation.proposal.title);assert.equal(recovered.draft.title,'旅行管家·实测'); + assert.equal(recovered.revision,creation.revision);assert.notEqual(recovered.seed,proposal.seed); + assert.deepEqual(requests,[{endpoint:'history',payload:{developmentId:creation.id}}]); + assert.equal(creation.proposal.title,'旅行管家·实测:多次旅行管理工作台'); +}); +test('late recovery preserves an already edited or explicitly selected review and its conflict token',()=>{ + const chosen={revision:'previous-creation-revision',seed:'user-selection',draft:{...draft,title:'正在编辑的旅行名称'}}; + assert.equal(restoreCreationReview(creation,[saved],chosen,true),chosen); + assert.equal(restoreCreationReview(creation,[],chosen),chosen); + assert.equal(chosen.revision,'previous-creation-revision'); +}); +test('new discussions without saved requirements use the proposal, but foreign saved drafts are rejected',()=>{ + assert.deepEqual(restoreCreationReview(creation,[]),creationProposalReview(creation)); + assert.equal(restoreCreationReview({...creation,proposal:undefined},[]),undefined); + assert.throws(()=>restoreCreationReview(creation,[{...saved,developmentId:'other-creation'}]),/workbench\/requirements-revision-conflict/); +}); diff --git a/packages/plugins/workbenches/tests/creation-rpc.test.mjs b/packages/plugins/workbenches/tests/creation-rpc.test.mjs index 2befaf28b9..7acae35f19 100644 --- a/packages/plugins/workbenches/tests/creation-rpc.test.mjs +++ b/packages/plugins/workbenches/tests/creation-rpc.test.mjs @@ -3,8 +3,8 @@ import assert from 'node:assert/strict'; import {createRequire} from 'node:module'; import {fileURLToPath} from 'node:url'; const require=createRequire(new URL('../../../../apps/web/package.json',import.meta.url)); -const built=await require('esbuild').build({entryPoints:[fileURLToPath(new URL('../src/runtime/creation-rpc.ts',import.meta.url))],bundle:true,platform:'node',format:'esm',write:false}); -const {handleWorkbenchCreationRequest}=await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString('base64')}`); +const built=await require('esbuild').build({stdin:{contents:"export {handleWorkbenchCreationRequest} from './creation-rpc.ts';export {WorkbenchCreationPreparationError} from './creation-development.ts';",resolveDir:fileURLToPath(new URL('../src/runtime',import.meta.url))},bundle:true,platform:'node',format:'esm',write:false}); +const {handleWorkbenchCreationRequest,WorkbenchCreationPreparationError}=await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString('base64')}`); const request=body=>new Request('http://localhost/api/workdsh-workbench-creation',{method:'POST',body:JSON.stringify(body)}); test('creation RPC never accepts caller Session, source, owner or confirmation authority',async()=>{ let opens=0,confirms=0; @@ -17,6 +17,13 @@ test('creation RPC never accepts caller Session, source, owner or confirmation a assert.equal((await handleWorkbenchCreationRequest(request({endpoint:'open',payload:{id:'id',idea:'idea'}}),service)).status,200); assert.equal(opens,1); }); +test('a failed continuation reports saved requirements, the actual safe code and the preparation stage',async()=>{ + const response=await handleWorkbenchCreationRequest(request({endpoint:'confirm',payload:{id:'id',expectedRevision:'rev',requirements:{}}}),{confirm:async()=>{throw new WorkbenchCreationPreparationError('source',Error('workbench/sdk-package-unavailable'));}}); + assert.equal(response.status,503);const body=await response.json(); + assert.equal(body.error.code,'workbench/sdk-package-unavailable');assert.equal(body.error.requirementsSaved,true);assert.equal(body.error.preparationStage,'source');assert.match(body.error.message,/需求已保存/); + const hidden=await handleWorkbenchCreationRequest(request({endpoint:'confirm',payload:{id:'id',expectedRevision:'rev',requirements:{}}}),{confirm:async()=>{throw new WorkbenchCreationPreparationError('source',Error('ENOENT /private/internal/path'));}}); + assert.doesNotMatch(JSON.stringify(await hidden.json()),/private\/internal/); +}); test('creation errors distinguish stale summaries and access denial without exposing internal paths',async()=>{ for(const [message,status,code] of [['workbench/creation-conflict',409,'workbench/creation-conflict'],['workbench/creation-access-denied',403,'workbench/creation-access-denied'],['ENOENT /home/private/source',503,'workbench/creation-failed']]){ const response=await handleWorkbenchCreationRequest(request({endpoint:'list',payload:{}}),{list:async()=>{throw Error(message);}}); diff --git a/packages/plugins/workbenches/tests/delivery-runner.test.mjs b/packages/plugins/workbenches/tests/delivery-runner.test.mjs index 3e605e243d..990b9271e8 100644 --- a/packages/plugins/workbenches/tests/delivery-runner.test.mjs +++ b/packages/plugins/workbenches/tests/delivery-runner.test.mjs @@ -83,3 +83,13 @@ test('a timed out build returns even when a detached grandchild holds stdout ope await cleanup(dir,result); } }); + +test('legacy packages cannot publish the development-only runtime anchor in their candidate archive',async()=>{ + const dir=await fixture();let result; + try{ + const manifest=JSON.parse(await readFile(join(dir,'package.json'),'utf8'));delete manifest.files; + await writeFile(join(dir,'package.json'),JSON.stringify(manifest)); + await writeFile(join(dir,'.workdsh-runtime.json'),JSON.stringify({schemaVersion:1,installationAnchor:fileURLToPath(new URL('../../../../apps/web/package.json',import.meta.url))})); + result=await runWorkbenchDelivery(dir);assert.equal(result.status,'failed');assert.equal(result.stages.at(-1).errorCode,'workbench/delivery-runtime-record-in-archive');assert.equal(result.candidate,undefined);assert.equal((await readdir(dir)).some(name=>name.endsWith('.tgz')),false); + }finally{await cleanup(dir,result);} +}); diff --git a/packages/plugins/workbenches/tests/development-starter.test.mjs b/packages/plugins/workbenches/tests/development-starter.test.mjs index 6dadd2256c..70b0d21b0f 100644 --- a/packages/plugins/workbenches/tests/development-starter.test.mjs +++ b/packages/plugins/workbenches/tests/development-starter.test.mjs @@ -1,4 +1,4 @@ -import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {fileURLToPath} from 'node:url';import {mkdtemp,writeFile,symlink,readFile,rm} from 'node:fs/promises';import {tmpdir} from 'node:os';import {join} from 'node:path';import {spawnSync} from 'node:child_process'; +import test from 'node:test';import assert from 'node:assert/strict';import {createRequire} from 'node:module';import {fileURLToPath} from 'node:url';import {mkdtemp,mkdir,cp,writeFile,symlink,readFile,rm} from 'node:fs/promises';import {tmpdir} from 'node:os';import {join,dirname} from 'node:path';import {spawnSync} from 'node:child_process';import{gunzipSync}from'node:zlib'; const require=createRequire(new URL('../../../../apps/web/package.json',import.meta.url));const built=await require('esbuild').build({entryPoints:[fileURLToPath(new URL('../src/runtime/development-starter.ts',import.meta.url))],bundle:true,platform:'node',format:'esm',write:false});const {createWorkbenchStarter}=await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString('base64')}`); test('generated starter builds actual Host and Client artifacts with stable package identity',async()=>{ const dir=await mkdtemp(join(tmpdir(),'workdsh-starter-')); @@ -29,3 +29,33 @@ test('confirmed system modules produce navigation without changing declaration i assert.ok(starter.files['client.jsx'].includes(JSON.stringify([['inspection','检验分析'],['report','报告中心']]))); assert.equal(starter.manifest.workdsh.workbench.id,'probe.modules'); }); + +test('the same Desktop starter builds, checks and delivers with an installation anchor and no Profile SDK tree',async()=>{ + const root=await mkdtemp(join(tmpdir(),'workdsh-desktop-starter-'));let report; + try{ + const profile=join(root,'home/profiles/workdsh'),source=join(profile,'workdsh-workbench-sources/probe'),installation=join(root,'installation'),sdk=join(installation,'node_modules/workdsh-plugin-workbenches'); + await mkdir(source,{recursive:true});await mkdir(sdk,{recursive:true}); + const original=fileURLToPath(new URL('..',import.meta.url)),sdkRequire=createRequire(join(original,'package.json')); + for(const file of ['package.json','dist','scripts'])await cp(join(original,file),join(sdk,file),{recursive:true}); + await writeFile(join(installation,'package.json'),'{}'); + for(const name of ['@deepseek-ai/cordis','semver']){ + const target=dirname(sdkRequire.resolve(name+'/package.json')),link=join(installation,'node_modules',name);await mkdir(dirname(link),{recursive:true});await symlink(target,link,process.platform==='win32'?'junction':'dir'); + } + const primary=join(root,'primary'),pnpm=fileURLToPath(new URL('../../../../apps/web/node_modules/pnpm',import.meta.url)); + await mkdir(join(primary,'dependencies'),{recursive:true});await symlink(pnpm,join(primary,'dependencies/pnpm'),process.platform==='win32'?'junction':'dir'); + await writeFile(join(primary,'runtime.json'),JSON.stringify({pnpm:JSON.parse(await readFile(join(pnpm,'package.json'),'utf8')).version})); + const descriptor={id:'probe.desktop-starter',title:'Desktop骨架验证',protocolVersion:'1.0',layout:'full-page',hostCompatibility:{sdk:'^1.0.0',harness:'0.2.1-rc.1'},capabilities:[]}; + const starter=createWorkbenchStarter('workdsh-probe-desktop-starter',descriptor);await writeFile(join(source,'package.json'),JSON.stringify(starter.manifest)); + for(const [name,content]of Object.entries(starter.files))await writeFile(join(source,name),content); + await writeFile(join(source,'.workdsh-runtime.json'),JSON.stringify({schemaVersion:1,installationAnchor:join(installation,'package.json')})); + await mkdir(join(source,'node_modules'));await symlink(dirname(require.resolve('esbuild/package.json')),join(source,'node_modules/esbuild'),process.platform==='win32'?'junction':'dir'); + assert.throws(()=>createRequire(join(source,'package.json')).resolve('workdsh-plugin-workbenches')); + assert.match(starter.files['.gitignore'],/\.workdsh-runtime\.json/); + const build=spawnSync(process.execPath,['build.mjs'],{cwd:source,encoding:'utf8',timeout:15000});assert.equal(build.status,0,build.stderr); + const check=spawnSync(process.execPath,[join(source,'check-startup.mjs')],{cwd:profile,encoding:'utf8',timeout:15000});assert.equal(check.status,0,check.stderr+check.stdout);assert.equal(JSON.parse(check.stdout).status,'passed'); + const delivery=spawnSync(process.execPath,[join(source,'deliver.mjs')],{cwd:profile,encoding:'utf8',timeout:15000,env:{...process.env,DSH_BUNDLED_PRIMARY_RUNTIME:primary}}); + assert.equal(delivery.status,0,delivery.stderr+delivery.stdout);report=JSON.parse(delivery.stdout);assert.equal(report.status,'passed');assert.deepEqual(report.stages.map(stage=>stage.name),['build','startup-check','pack']); + const tar=gunzipSync(await readFile(join(source,report.candidate.filename))).toString('utf8');assert.equal(tar.includes('package/.workdsh-runtime.json\0'),false);assert.equal(tar.includes(installation),false); + await assert.rejects(readFile(join(profile,'node_modules/package.json')),error=>error.code==='ENOENT'); + }finally{await rm(root,{recursive:true,force:true});if(report)await rm(dirname(report.reportPath),{recursive:true,force:true});} +}); diff --git a/packages/plugins/workbenches/tests/profile-development-source.test.mjs b/packages/plugins/workbenches/tests/profile-development-source.test.mjs index e9db6171ee..fe76979666 100644 --- a/packages/plugins/workbenches/tests/profile-development-source.test.mjs +++ b/packages/plugins/workbenches/tests/profile-development-source.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { createRequire } from 'node:module'; @@ -23,6 +23,7 @@ test('Profile source adapter creates real metadata, restores directories and rej await writeFile(join(root,'node_modules/workdsh-plugin-workbenches/package.json'),JSON.stringify({name:'workdsh-plugin-workbenches',version:'3.0.0-alpha.99'})); const verify = factory(ctx); const source = await verify(actor, development); const metadata=JSON.parse(await readFile(join(source.directory,'package.json'),'utf8'));assert.equal(metadata.name,'quality-plugin');assert.equal(metadata.peerDependencies['workdsh-plugin-workbenches'],'^3.0.0-alpha.99');assert.equal(metadata.workdsh.workbench.title,'检验工作台'); + assert.deepEqual(JSON.parse(await readFile(join(source.directory,'.workdsh-runtime.json'),'utf8')),{schemaVersion:1,installationAnchor:join(await realpath(root),'package.json')}); await assert.rejects(verify(actor,{...development,requirementsRevision:'missing'}),/requirements-revision-conflict/); const original=requirement;requirement={...requirement,owner:{...requirement.owner,ownerPrincipalId:'other'}};await assert.rejects(verify(actor,development),/requirements-revision-conflict/);requirement=original; assert.deepEqual(await verify(actor, { ...development, source }), source); @@ -37,3 +38,27 @@ test('Profile source adapter creates real metadata, restores directories and rej await assert.rejects(verify(actor, development), /source-directory-outside/); } finally { await rm(root, { recursive: true, force: true }); } }); +test('Desktop source preparation uses the installation anchor without writable Profile node_modules',async()=>{ + const root=await mkdtemp(join(tmpdir(),'workbench-source-installation-')); + const profile={name:'workdsh',dir:join(root,'home/profiles/workdsh'),home:join(root,'home'),installAnchor:join(root,'installation/profile-installation.json')}; + const actor={principalId:'user',organizationId:'org',resolvedBy:'fixture'}; + const identity={profile:()=>({principalId:'user',organization:{id:'org'},resolvedBy:'fixture'}),membership:()=>({state:'active'})}; + const requirements={history:async()=>[{id:'requirements-1',owner:{organizationId:'org',ownerPrincipalId:'user'},requirements:{title:'旅行管家·实测'}}]}; + const ctx={get:key=>key==='profileContext'?profile:key==='workdshWorkbenchRequirements'?requirements:identity,fiber:{assertActive(){}}}; + const development={id:'desktop-dev',requirementsRevision:'requirements-1',workbenchId:'workdsh.user-desktop',packageName:'workdsh-desktop-probe',owner:{organizationId:'org',ownerPrincipalId:'user'},target:{hostId:'host'}}; + try{ + await mkdir(profile.dir,{recursive:true});await mkdir(join(root,'installation/node_modules/workdsh-plugin-workbenches'),{recursive:true}); + await writeFile(profile.installAnchor,'{}'); + const sdk=join(root,'installation/node_modules/workdsh-plugin-workbenches/package.json'); + await writeFile(sdk,JSON.stringify({name:'workdsh-plugin-workbenches',version:'3.0.0-alpha.99'})); + await assert.rejects(access(join(profile.dir,'node_modules'))); + const verify=factory(ctx),source=await verify(actor,development); + const manifest=JSON.parse(await readFile(join(source.directory,'package.json'),'utf8')); + assert.equal(manifest.workdsh.workbench.title,'旅行管家·实测');assert.equal(manifest.peerDependencies['workdsh-plugin-workbenches'],'^3.0.0-alpha.99'); + assert.deepEqual(JSON.parse(await readFile(join(source.directory,'.workdsh-runtime.json'),'utf8')),{schemaVersion:1,installationAnchor:profile.installAnchor}); + assert.equal(manifest.files.includes('.workdsh-runtime.json'),false); + await assert.rejects(access(join(profile.dir,'node_modules')),'source preparation must not manufacture a duplicate installation'); + await rm(sdk);await assert.rejects(verify(actor,development),/workbench\/sdk-package-unavailable/); + profile.installAnchor=join(root,'another-installation/package.json');await assert.rejects(verify(actor,development),/installation-target-changed/); + }finally{await rm(root,{recursive:true,force:true});} +}); diff --git a/packages/plugins/workbenches/tests/requirements-save-error.test.mjs b/packages/plugins/workbenches/tests/requirements-save-error.test.mjs new file mode 100644 index 0000000000..cfa28df95e --- /dev/null +++ b/packages/plugins/workbenches/tests/requirements-save-error.test.mjs @@ -0,0 +1,13 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +const require=createRequire(new URL('../../../../apps/web/package.json',import.meta.url)); +const built=await require('esbuild').build({entryPoints:[fileURLToPath(new URL('../src/client/requirements-save-error.ts',import.meta.url))],bundle:true,platform:'node',format:'esm',write:false}); +const {requirementsSaveFailureMessage}=await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString('base64')}`); +test('partial confirmation never tells the user saved requirements were lost',()=>{ + const message=requirementsSaveFailureMessage({code:'workbench/sdk-package-unavailable',requirementsSaved:true,preparationStage:'source'}); + assert.match(message,/需求已保存/);assert.match(message,/SDK/);assert.doesNotMatch(message,/未保存成功/); + assert.match(requirementsSaveFailureMessage({code:'workbench/unexpected',requirementsSaved:true,preparationStage:'session'}),/会话尚未关联/); + assert.doesNotMatch(requirementsSaveFailureMessage(Error('/private/unsafe')),/private/); +}); diff --git a/packages/plugins/workbenches/tests/startup-check.test.mjs b/packages/plugins/workbenches/tests/startup-check.test.mjs index 88a8c6f6c2..fe5b7f0649 100644 --- a/packages/plugins/workbenches/tests/startup-check.test.mjs +++ b/packages/plugins/workbenches/tests/startup-check.test.mjs @@ -6,6 +6,7 @@ import {join,dirname} from 'node:path'; import {spawnSync} from 'node:child_process'; import {readdir} from 'node:fs/promises'; import {fileURLToPath} from 'node:url'; +import {createRequire} from 'node:module'; import {checkWorkbenchStartup} from '../scripts/check-startup.mjs'; const modules=fileURLToPath(new URL('../../../../apps/web/node_modules',import.meta.url)); async function candidate(body,change={}){ @@ -64,3 +65,39 @@ test('an absent explicitly optional peer does not fail an otherwise complete Hos const f=await candidate(`export const inject=['workdshWorkbenchRegistration'];export async function apply(ctx){${register}}`,{peerDependencies:{'@deepseek-ai/cordis':'4.0.5','workdsh-absent-optional-probe':'^1.0.0'},peerDependenciesMeta:{'workdsh-absent-optional-probe':{optional:true}}}); try{assert.equal((await checkWorkbenchStartup(f.directory)).status,'passed');}finally{await f.close();} }); + +async function anchoredCandidate(body,change={}){ + const fixture=await candidate(body,{peerDependencies:{'@deepseek-ai/cordis':'4.0.5','workdsh-plugin-workbenches':'^3.0.0-alpha.7'},...change}); + const installation=await mkdtemp(join(tmpdir(),'workdsh-startup-installation-')); + await rm(join(fixture.directory,'node_modules')); + const sdk=fileURLToPath(new URL('..',import.meta.url)),require=createRequire(join(sdk,'package.json')); + await writeFile(join(installation,'package.json'),'{}'); + for(const name of ['workdsh-plugin-workbenches','@deepseek-ai/cordis','zod']){ + const target=name==='workdsh-plugin-workbenches'?sdk:dirname(require.resolve(name+'/package.json')); + const link=join(installation,'node_modules',name);await mkdir(dirname(link),{recursive:true});await symlink(target,link,process.platform==='win32'?'junction':'dir'); + } + await writeFile(join(fixture.directory,'.workdsh-runtime.json'),JSON.stringify({schemaVersion:1,installationAnchor:join(installation,'package.json')})); + return {...fixture,close:async()=>{await fixture.close();await rm(installation,{recursive:true,force:true});}}; +} +test('a Desktop source without node_modules verifies declared host peers through its explicit installation anchor',async()=>{ + const fixture=await anchoredCandidate(`export const inject=['workdshWorkbenchRegistration'];export async function apply(ctx){${register}}`); + try{const result=await checkWorkbenchStartup(fixture.directory);assert.equal(result.status,'passed',JSON.stringify(result));assert.equal((await readdir(fixture.directory)).includes('node_modules'),false);}finally{await fixture.close();} +}); +test('the installation anchor cannot hide missing business dependencies, even when installed in the Host',async()=>{ + const fixture=await anchoredCandidate('export function apply(){}',{dependencies:{zod:'4.4.3'}}); + try{const result=await checkWorkbenchStartup(fixture.directory);assert.equal(result.errorCode,'workbench/startup-missing-dependencies');assert.deepEqual(result.missingDependencies,['zod']);}finally{await fixture.close();} + const direct=await anchoredCandidate('export function apply(){}',{dependencies:{'@deepseek-ai/cordis':'4.0.5'}}); + try{const result=await checkWorkbenchStartup(direct.directory);assert.deepEqual(result.missingDependencies,['@deepseek-ai/cordis']);}finally{await direct.close();} +}); +test('anchored host peers must satisfy the candidate declaration',async()=>{ + const fixture=await anchoredCandidate('export function apply(){}',{peerDependencies:{'@deepseek-ai/cordis':'^99.0.0'}}); + try{const result=await checkWorkbenchStartup(fixture.directory);assert.equal(result.errorCode,'workbench/startup-dependency-version-mismatch');assert.equal(result.dependencyMismatches[0].actual,'4.0.5');}finally{await fixture.close();} +}); +test('real candidate bare imports still fail under native Node rather than using a manufactured module loader',async()=>{ + const fixture=await anchoredCandidate(`import {Context} from '@deepseek-ai/cordis';export const inject=['workdshWorkbenchRegistration'];export async function apply(ctx){${register}if(!Context)throw Error();}`); + try{const result=await checkWorkbenchStartup(fixture.directory);assert.equal(result.status,'failed');assert.equal(result.stage,'lifecycle');assert.equal(result.errorCode,'workbench/startup-module-not-found');}finally{await fixture.close();} +}); +test('a malformed platform runtime record is an explicit failure',async()=>{ + const fixture=await anchoredCandidate('export function apply(){}'); + try{await writeFile(join(fixture.directory,'.workdsh-runtime.json'),JSON.stringify({schemaVersion:1,installationAnchor:'./not-an-installation'}));const result=await checkWorkbenchStartup(fixture.directory);assert.equal(result.errorCode,'workbench/startup-invalid-runtime-anchor');}finally{await fixture.close();} +}); From c581f55a729290a14ef1942bb39136370f8805d1 Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Sun, 18 Oct 2026 13:22:22 +0800 Subject: [PATCH 5/7] Resume existing native creation sessions without creating replacement conversations --- .../workbenches/src/runtime/creation-host.ts | 8 ++++ .../src/runtime/resume-creation-session.ts | 23 +++++++++ .../tests/creation-host-resume.test.mjs | 48 +++++++++++++++++++ .../tests/resume-creation-session.test.mjs | 36 ++++++++++++++ 4 files changed, 115 insertions(+) create mode 100644 packages/plugins/workbenches/src/runtime/resume-creation-session.ts create mode 100644 packages/plugins/workbenches/tests/creation-host-resume.test.mjs create mode 100644 packages/plugins/workbenches/tests/resume-creation-session.test.mjs diff --git a/packages/plugins/workbenches/src/runtime/creation-host.ts b/packages/plugins/workbenches/src/runtime/creation-host.ts index f667e4ce12..e8dfbf0e91 100644 --- a/packages/plugins/workbenches/src/runtime/creation-host.ts +++ b/packages/plugins/workbenches/src/runtime/creation-host.ts @@ -16,6 +16,7 @@ import type {mountProfileWorkbenchAuthoringHost} from './profile-authoring-host. import {handleWorkbenchCreationRequest} from './creation-rpc.js'; import {defineTool,type ToolRunContext} from '@deepseek-ai/dsh-tools'; import {confirmCreationDevelopment} from './creation-development.js'; +import {resumeWorkbenchCreationSession} from './resume-creation-session.js'; export const workbenchCreationPath='/api/workdsh-workbench-creation'; export async function mountWorkbenchCreationHost(ctx:Context,authoring:Awaited>){ @@ -55,6 +56,13 @@ export async function mountWorkbenchCreationHost(ctx:Context,authoring:Awaitedinspector.inspect(sessionId,signal), + projections:(request,signal)=>ctx.sessionController.projections(request,signal), + },check,signal); + } const {workspace,created}=await ctx.workspaceController.create({path:directory});check(); if(created){await ctx.workspaceController.rename({workspaceId:workspace.workspaceId,title:`制作 · ${row.idea.replace(/\s+/g,' ').slice(0,32)}`});check();} const request:Parameters[0]={sessionId:id as never,workspaceId:workspace.workspaceId,agentPreset:'cordis'}; diff --git a/packages/plugins/workbenches/src/runtime/resume-creation-session.ts b/packages/plugins/workbenches/src/runtime/resume-creation-session.ts new file mode 100644 index 0000000000..da4a1d19cd --- /dev/null +++ b/packages/plugins/workbenches/src/runtime/resume-creation-session.ts @@ -0,0 +1,23 @@ +import {realpath} from 'node:fs/promises'; +import type {Context} from '@deepseek-ai/cordis'; +import type {} from '@deepseek-ai/dsh-api-session-controller'; +import type {ActorContext} from 'workdsh-contracts'; + +/** create() is a new-conversation command: a named started Session causes the + * official controller to mint another identity. Resume must use cold reads. */ +export async function resumeWorkbenchCreationSession(actor:ActorContext,sessionId:string,expectedId:string,directory:string, + ports:{authorize(actor:ActorContext,id:string,signal?:AbortSignal):Promise;inspect:Context['sessionController']['inspect'];projections:Context['sessionController']['projections']}, + check:()=>void,signal?:AbortSignal){ + check();if(sessionId!==expectedId)throw Error('workbench/creation-conflict'); + await ports.authorize(actor,sessionId,signal);check(); + // Missing, unauthorized, corrupt and cancelled reads remain failures. None is + // interpreted as permission to create or adopt another Session. + const inspected=await ports.inspect(sessionId as never,signal);check(); + if(String(inspected.meta.id)!==sessionId||inspected.meta.origin==='subagent')throw Error('workbench/creation-conflict'); + if(!inspected.meta.cwd||await realpath(inspected.meta.cwd)!==directory)throw Error('workbench/session-workspace-mismatch'); + check();const projected=await ports.projections({sessionId:sessionId as never},signal??new AbortController().signal);check(); + if(projected?.kind!=='sequenced')throw Error('workbench/session-state-unavailable'); + if(projected.values.agentPreset!=='cordis')throw Error('workbench/development-creator-required'); + await ports.authorize(actor,sessionId,signal);check(); + return sessionId; +} diff --git a/packages/plugins/workbenches/tests/creation-host-resume.test.mjs b/packages/plugins/workbenches/tests/creation-host-resume.test.mjs new file mode 100644 index 0000000000..c72c05ed3f --- /dev/null +++ b/packages/plugins/workbenches/tests/creation-host-resume.test.mjs @@ -0,0 +1,48 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {fileURLToPath,pathToFileURL} from 'node:url'; +import {mkdtemp,mkdir,writeFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +const web=createRequire(new URL('../../../../apps/web/package.json',import.meta.url)); +const runtime=createRequire(new URL('../../projects/package.json',import.meta.url)); +const localRuntime=createRequire(new URL('../package.json',import.meta.url)); +const built=await web('esbuild').build({stdin:{contents:"export {mountWorkbenchCreationHost} from './creation-host.ts';export {createProfileWorkbenchTarget} from './profile-target.ts';export {WorkbenchRequirementsService} from './requirements-service.ts';export {WorkbenchDevelopmentService} from './development-service.ts';export {restoreCreationReview} from '../client/creation-review.ts';",resolveDir:fileURLToPath(new URL('../src/runtime',import.meta.url))},bundle:true,platform:'node',format:'esm',write:false,alias:{'workdsh-contracts':fileURLToPath(new URL('../../../contracts/src/index.ts',import.meta.url))},plugins:[{name:'official',setup(builder){builder.onResolve({filter:/^(?:@deepseek-ai\/|zod$)/},args=>({path:pathToFileURL(localRuntime.resolve(args.path)).href,external:true}));}}]}); +const {mountWorkbenchCreationHost,createProfileWorkbenchTarget,WorkbenchRequirementsService,WorkbenchDevelopmentService,restoreCreationReview}=await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString('base64')}`); +const load=name=>import(pathToFileURL(runtime.resolve(name)).href); +const {Context}=await load('@deepseek-ai/cordis');const {default:Storage}=await load('@deepseek-ai/dsh-storage');const Json=await load('@deepseek-ai/dsh-storage-json');const Domain=await load('@deepseek-ai/dsh-storage-domain'); +for(const existingBehavior of ['mint-new-id','reject-existing'])test(`the mounted Host reopens a started Session (${existingBehavior}) after partial confirmation without creating another Session`,async()=>{ + const root=await mkdtemp(join(tmpdir(),'workdsh-host-creation-resume-')),profileDir=join(root,'profile');await mkdir(profileDir);await writeFile(join(profileDir,'package.json'),'{}'); + const owners=new Map(),sessions=new Map(),workspaces=new Map();let creates=0,boot; + const actor={principalId:'member',organizationId:'personal',requestId:'fixture',resolvedBy:'fixture'}; + const identity={resolve:async()=>actor,profile:()=>({principalId:actor.principalId,organization:{id:actor.organizationId,kind:'personal'},resolvedBy:actor.resolvedBy}),membership:()=>({state:'active'})}; + const access={sessionOwner:id=>owners.get(id),bindSession:async(_actor,{sessionId})=>{const owner={ownerPrincipalId:actor.principalId,organizationId:actor.organizationId};owners.set(sessionId,owner);return owner;}}; + const native={ + create:async request=>{creates++;const previous=sessions.get(request.sessionId);if(previous?.started){if(existingBehavior==='reject-existing')throw Error('already-started');return {sessionId:'new-blank-instead-of-started',agentPreset:'cordis'};}const cwd=workspaces.get(request.workspaceId);sessions.set(request.sessionId,{meta:{id:request.sessionId,cwd,agentPreset:'cordis'},started:false});return {sessionId:request.sessionId,agentPreset:'cordis'};}, + inspect:async id=>{const found=sessions.get(id);if(!found)throw Object.assign(Error('missing-session'),{code:'session/not-found'});return {meta:found.meta,events:found.started?[{type:'turn/start',seq:1}]:[]};}, + projections:async({sessionId})=>({kind:'sequenced',asOfSeq:sessions.get(sessionId)?.started?1:0,values:{agentPreset:'cordis'}}), + }; + async function start(){ + const storageCtx=new Context();await storageCtx.plugin(Storage);await storageCtx.plugin(Json,{root:join(root,'storage')});await storageCtx.plugin(Domain,{backend:'json'}); + const services={profileContext:{name:'test',dir:profileDir,home:root},workdshIdentity:identity,workdshAccess:access,storageDomain:storageCtx.storageDomain,sessionController:native,connection:{fetch:{register:()=>()=>{}}}}; + const ctx={get:key=>services[key],fiber:{assertActive(){}},effect:()=>{},tools:{register(){}},sessionController:native,workspaceController:{create:async({path})=>{let entry=[...workspaces].find(([,cwd])=>cwd===path);const created=!entry;if(!entry){entry=[`workspace-${workspaces.size}`,path];workspaces.set(...entry);}return {workspace:{workspaceId:entry[0]},created};},rename:async()=>{}}}; + const requirements=new WorkbenchRequirementsService(storageCtx.storageDomain,identity),development=new WorkbenchDevelopmentService(storageCtx.storageDomain,identity,requirements,await createProfileWorkbenchTarget(ctx));services.workdshWorkbenchRequirements=requirements; + const service=await mountWorkbenchCreationHost(ctx,{requirements,development}); + return {service,requirements,development,close:async()=>{await service.close();await development.close();await requirements.close();await storageCtx.fiber.dispose();}}; + } + const draft={title:'旅行管家·实测',scenario:'管理多次旅行',users:'个人',dataSources:'手动录入',workflow:'旅行列表、行程、费用、待办',firstIteration:'新增和独立管理多次旅行',acceptance:'关闭重开数据保留'},id='d22cc68e-4b3d-4c97-825d-2db6f3769bbe'; + try{ + boot=await start();const first=await boot.service.open(id,'长期管理多次旅行');sessions.get(first.sessionId).started=true; + const proposed=await boot.service.propose(first.sessionId,first.revision,{...draft,title:'AI旧标题'}); + await assert.rejects(boot.service.confirm(id,proposed.revision,draft),error=>error.message==='workbench/sdk-package-unavailable'&&error.requirementsSaved); + await boot.close();boot=undefined; + await mkdir(join(profileDir,'node_modules','workdsh-plugin-workbenches'),{recursive:true});await writeFile(join(profileDir,'node_modules','workdsh-plugin-workbenches','package.json'),JSON.stringify({name:'workdsh-plugin-workbenches',version:'3.0.0-alpha.49'})); + boot=await start();const reopened=await boot.service.open(id,'长期管理多次旅行'); + assert.equal(reopened.sessionId,first.sessionId);assert.equal(reopened.needsOpening,false);assert.equal(creates,1);assert.equal(sessions.size,1); + const recovered=restoreCreationReview(reopened,await boot.requirements.history(id));assert.equal(recovered.draft.title,'旅行管家·实测'); + const confirmed=await boot.service.confirm(id,recovered.revision,recovered.draft);assert.equal(confirmed.creation.phase,'confirmed');assert.equal(confirmed.development.development.session.sessionId,first.sessionId); + await boot.service.confirm(id,recovered.revision,recovered.draft); + assert.equal((await boot.requirements.history(id)).length,1);assert.equal((await boot.development.list()).length,1);assert.equal(creates,1);assert.equal(sessions.size,1); + }finally{await boot?.close();await rm(root,{recursive:true,force:true});} +}); diff --git a/packages/plugins/workbenches/tests/resume-creation-session.test.mjs b/packages/plugins/workbenches/tests/resume-creation-session.test.mjs new file mode 100644 index 0000000000..e463175a91 --- /dev/null +++ b/packages/plugins/workbenches/tests/resume-creation-session.test.mjs @@ -0,0 +1,36 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {fileURLToPath} from 'node:url'; +import {mkdtemp,realpath,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +const web=createRequire(new URL('../../../../apps/web/package.json',import.meta.url)); +const built=await web('esbuild').build({entryPoints:[fileURLToPath(new URL('../src/runtime/resume-creation-session.ts',import.meta.url))],bundle:true,platform:'node',format:'esm',write:false}); +const {resumeWorkbenchCreationSession:resume}=await import(`data:text/javascript;base64,${Buffer.from(built.outputFiles[0].text).toString('base64')}`); +const actor={principalId:'member',organizationId:'personal',resolvedBy:'fixture'}; +test('a started native Session resumes by authorized cold reads and never invokes new-conversation create',async()=>{ + const directory=await realpath(await mkdtemp(join(tmpdir(),'workdsh-native-resume-'))); + try{for(const createBehavior of ['mint-new-id','reject-existing']){ + const calls=[]; + const native={create:async()=>{calls.push('create');if(createBehavior==='reject-existing')throw Error('already-exists');return {sessionId:'another-new-blank',agentPreset:'cordis'};},inspect:async id=>{calls.push('inspect');assert.equal(id,'started-original');return {meta:{id,cwd:directory,agentPreset:'cordis'},events:[{type:'turn/start',seq:1}]};},projections:async()=>{calls.push('projections');return {kind:'sequenced',asOfSeq:1,values:{agentPreset:'cordis'}};}}; + const restored=await resume(actor,'started-original','started-original',directory,{...native,authorize:async()=>{calls.push('authorize');}},()=>{}); + assert.equal(restored,'started-original');assert.deepEqual(calls,['authorize','inspect','projections','authorize']); + }}finally{await rm(directory,{recursive:true,force:true});} +}); +test('resume preserves identity, ownership, workspace and current preset fences; failed reads never create a substitute',async()=>{ + const directory=await realpath(await mkdtemp(join(tmpdir(),'workdsh-native-resume-fences-'))); + try{ + const expected='original';let creates=0; + const normal={authorize:async()=>{},inspect:async()=>({meta:{id:expected,cwd:directory},events:[]}),projections:async()=>({kind:'sequenced',asOfSeq:0,values:{agentPreset:'cordis'}}),create:async()=>{creates++;}}; + await assert.rejects(resume(actor,'another',expected,directory,normal,()=>{}),/creation-conflict/); + await assert.rejects(resume(actor,expected,expected,directory,{...normal,authorize:async()=>{throw Error('workbench/creation-access-denied');}},()=>{}),/creation-access-denied/); + const readError=Object.assign(Error('not-readable'),{code:'session/not-found'}); + await assert.rejects(resume(actor,expected,expected,directory,{...normal,inspect:async()=>{throw readError;}},()=>{}),error=>error===readError); + await assert.rejects(resume(actor,expected,expected,directory,{...normal,inspect:async()=>({meta:{id:expected,cwd:tmpdir()},events:[]})},()=>{}),/session-workspace-mismatch/); + await assert.rejects(resume(actor,expected,expected,directory,{...normal,projections:async()=>({kind:'sequenced',asOfSeq:1,values:{agentPreset:'standard'}})},()=>{}),/development-creator-required/); + await assert.rejects(resume(actor,expected,expected,directory,{...normal,projections:async()=>({kind:'migration-required',values:{agentPreset:'cordis'}})},()=>{}),/session-state-unavailable/); + const controller=new AbortController();controller.abort();await assert.rejects(resume(actor,expected,expected,directory,normal,()=>controller.signal.throwIfAborted(),controller.signal),error=>error.name==='AbortError'); + assert.equal(creates,0); + }finally{await rm(directory,{recursive:true,force:true});} +}); From 5576e4726797a096b4c3630cd43f0fb6d90e55a5 Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Sun, 18 Oct 2026 13:38:09 +0800 Subject: [PATCH 6/7] Clarify shared workbench handoff and keep timing limited to test telemetry --- .../skills/workdsh-workbench-dev/SKILL.md | 4 +++- .../references/first-development.md | 2 +- .../references/iteration.md | 2 ++ .../references/startup-validation.md | 4 ++++ .../skills/src/authoring/professional.ts | 2 +- packages/plugins/workbenches/README.md | 2 +- .../src/client/WorkbenchDelivery.tsx | 3 +-- .../src/client/WorkbenchTiming.tsx | 22 ------------------- .../src/runtime/development-tools.ts | 4 ++-- .../runtime/native-installation-handoff.ts | 20 +++++++++++++++++ .../src/runtime/native-installation-host.ts | 4 +++- .../tests/development-tools.test.mjs | 18 +++++++++++++++ .../tests/native-installation-client.test.mjs | 3 ++- .../tests/native-installation-host.test.mjs | 10 +++++++++ 14 files changed, 68 insertions(+), 32 deletions(-) delete mode 100644 packages/plugins/workbenches/src/client/WorkbenchTiming.tsx create mode 100644 packages/plugins/workbenches/src/runtime/native-installation-handoff.ts diff --git a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/SKILL.md b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/SKILL.md index dcd724660a..2b1dc4b433 100644 --- a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/SKILL.md +++ b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/SKILL.md @@ -17,9 +17,11 @@ description: 在官方 Creator 插件开发流程上补充 WorkDSH 业务 SDK、 4. Excel、CSV 等文件复用系统 Office 的公开预览、编辑、保存和导出能力,不另写电子表格编辑器。业务页面负责字段对应、核对和统计;资料引用准确的资产及版本,统计由代码对完整已授权输入的固定修订确定性计算,不能拿截断预览作统计源。保存新文件版本后明确采用,历史分析与报告保留原快照。 5. 对象、输入版本、会话和身份必须匹配;切换对象、账号或迭代后,旧回复不能写入新对象。保持现有源码、会话历史、用户草稿和业务数据,交接不能覆盖未发送的输入。企业请求只走 Host 已授权业务服务,不向页面提供通用带凭据代理;token、密码和密钥不进入页面、提示词、源码、日志、导出或插件包。 6. 工作台默认按可持续使用的软件交付:围绕业务记录组织首页、列表、详情与返回路径,复杂任务使用应用内导航或页签,不把全部功能纵向堆成无限长页;多记录和页面数量根据本轮业务决定,用户明确要求的一次性工具除外。具体执行 [软件形态要求](references/product-experience.md)。页面使用中文业务字段,说明当前任务、结果与下一步,呈现正常、异常、空数据和失败恢复状态。业务插件不自行增加宿主一级菜单;平台统一通过原生“工作台”入口提供已安装应用及开发记录,不生成平级菜单。只使用实际已声明的官方公开插槽;不修改官方侧栏包、注入私有 DOM 或用缩进伪造菜单分组。平台提供的返回工作台和常用应用设置不在业务页重复实现;业务内部仍需提供返回模块或记录列表的路径,不能用浏览器返回代替。具体记录直达需要稳定对象标识和访问核对,不能拿会话链接冒充。局部修改沿用同一源码和插件身份。 -7. 通用安装使用官方 `plugin_manager` 的 `install_bundle` 及其真实回执,不手改 Profile、不复制安装器。在用户授权的安装阶段,以实际构建的 tgz 绝对路径为 target、`enabled:false` 安装候选,避免平台尚未核对归档时尝试注册。随后调用 `workbench_verify_native_installation`,仅传实际候选的 version、sha256;开发记录、源码和安装目标由宿主按原生会话归属核对。它核对实际安装字节并登记,不再次安装、不自动启用或重启。安装结果未知时先重新读取开发上下文并核对事实,不重复安装。核对后用户可在工作台列表启用;安装、登记、活动版本、Host/Client 注册和页面实际打开分别报告,更新需要重启时明确说明,不能自行重启其他任务。 +7. 通用安装使用官方 `plugin_manager install_bundle`,以已核实 tgz 绝对路径、`enabled:false` 安装;再用候选 version、sha256 调用 `workbench_verify_native_installation` 核对字节并登记。`activationVerified:false` 只表示本工具未核验启用,绝不表示停用或启用失败,不因此重复安装。用户已授权启用或打开试用时,按回执 `list_bundles`(沿 nextOffset 分页)找到实际包名并核对版本、error,再 `set_bundle`,target 为该包名、enabled 为 true;读取 application、warnings、nextStep。不能猜 entryId、拼 include: 前缀或用 `set_plugin` 代替 bundle 选择;不能手改 Profile。不具备启用授权时,由用户在工作台列表启用。安装结果未知先核对事实;restart-required 如实报告,不自行重启其他任务。安装、登记、活动版本和页面可用分别报告。 8. 依据用户本轮授权推进。要求先讨论时先明确工作路径;要求看效果时提供可交互效果并说明模拟范围;暂停打包时不自动构建或安装。生成代码、包检查通过、安装成功和页面可用分别报告;构建与 Slot 注册不能代替真实服务和用户操作验收。 +页面验收在当前已认证的宿主应用页面操作工作台:Desktop 使用正在运行的 WebView;Web 使用当前已登录应用页面。独立任务浏览器不自动共享宿主登录态,其 401 不能证明工作台失败。此时停止独立浏览器验收路径,返回已认证宿主页面或保留“页面未验收”,不得查找、读取或复制 token、cookie、凭据文件,或添加认证请求头绕过访问控制。源码和静态检查仍可继续,但不能冒充宿主页面操作。 + ## 每轮交付 保存需求修订、源码关联和修订、当前迭代、候选版本及摘要、目标安装实例、官方安装与平台登记的实际结果、业务验收和未完成项。候选包存在、安装字节匹配不证明它由当前源码修订生成,构建来源仍需实际证据。源码和需求文件提供恢复上下文;事实以当前运行时及持久记录为准。中断后先核对结果,不自动重复安装或创建会话。 diff --git a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/first-development.md b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/first-development.md index 5dda737c11..a42a8f9de4 100644 --- a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/first-development.md +++ b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/first-development.md @@ -5,7 +5,7 @@ 3. 优先使用开发上下文内联的 SDK 最短示例、版本和现有模板,直接开始本轮业务实现;只核对本轮新增接口,不重复查已确认的注册与构建方式。真实错误或版本变化时再查对应公开契约。使用官方公开模板或已交付 WorkDSH 模板在工作目录开发,不引用机器上的产品源码或绝对 node_modules 路径。保持稳定包名和工作台 ID,静态声明与运行注册一致,清理由插件生命周期管理。 4. 先完成一轮能实际操作的业务流程。AI 协作另外验收:绑定当前对象与完整输入版本 → 原生会话执行真实业务工具 → 候选回到对应对象 → 页面读取展示 → 用户继续加工或采用。切换对象后旧结果不能误写;示例数据明确标注,真实来源未验收不能声称联调完成。仅生成 Agent preset 或配置 bundle,不等于交付了业务 Client UI。 5. 通过上下文 `sdk.delivery.runner` 或骨架 `node deliver.mjs` 统一构建、启动检查、打包并记录阶段耗时;不逐个编写检查脚本、不重复手动 build/pack。按 [启动检查](startup-validation.md) 核对产物的真实工具定义与完整插件生命周期,保留具体失败原因;构建成功不能代替启动通过。在用户授权的阶段,依照官方 Skill 与实际模板检查、构建,重新读取开发上下文核对真实候选版本及 tgz 内容摘要。包存在不证明构建来源与当前源码一致。用原生 `plugin_manager` 的 `install_bundle` 安装实际候选,target 为经核实的 tgz 绝对路径、`enabled:false`,读取 `application`、`warnings`。随后以候选的 version、sha256 调用 `workbench_verify_native_installation`,由宿主检查本会话归属、实际安装字节和平台登记。该工具不重新安装、不启用、不重启;回执丢失时先核对已安装结果,不重复执行安装。不具备工具或校验失败时保留实际结果和具体未完成项。 -6. 平台核对登记后,用户可从工作台列表启用并打开。需要重启才能应用更新时说明原因与当前活动版本,不自行重启无关任务。核对实际活动版本、Host/Client 注册和业务页面,逐项操作本轮流程;让用户从该工作台实际试用。用户要求先看效果或暂停打包时止于该阶段,模拟边界与未完成验收单独说明。通过后保存已验收版本,其余需求保持待办。 +6. 核验回执 `activationVerified:false` 表示“未核验启用”,不是启用失败,不重复安装。已有启用/试用授权时,按回执 `plugin_manager list_bundles` 找到实际包名(需要时沿 nextOffset 分页),核对版本和 error,再 `set_bundle`(target 为包名、enabled:true);不猜 entryId、include: 前缀或用 set_plugin 代替。未获授权时由用户从工作台列表启用并打开。读取 application、warnings、nextStep;需要重启时说明原因及当前活动版本,不自行重启无关任务。在当前已认证的宿主应用页面核对 Host/Client、逐项操作本轮业务并重开检查数据:Desktop 用正在运行的 WebView,Web 用当前已登录应用页面。独立任务浏览器不自动共享宿主登录态,401 时停止该路径,不查找/读取/复制 token、cookie 或凭据绕过。未能操作已认证宿主页面时明确保留“页面未验收”。通过后保存已验收版本,其余需求保持待办。 ## 开发指令需要携带的上下文 diff --git a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/iteration.md b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/iteration.md index c99de4258b..85c866fca7 100644 --- a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/iteration.md +++ b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/iteration.md @@ -8,6 +8,8 @@ 按官方 Skill 检查当前公开 API 和工具权限,在原源码上调整并验证受影响流程。安装更新遵循用户本轮授权和官方工具链,不另造安装器或手改 Profile。重新读取开发上下文核对需求基线和实际候选;候选存在不证明构建来源与当前源码一致。以实际 tgz 绝对路径为 target、`enabled:false` 调用原生 `plugin_manager install_bundle`,保留真实 `application`、`warnings`,随后以候选 version、sha256 调用 `workbench_verify_native_installation` 核对实际安装并登记。校验不再次安装、不启用或重启;旧候选不能覆盖新版本,同版本不同内容必须更正版本。已有版本更新可能需要重启,分别记录已安装、已登记、活动版本和已打开状态,不把安装成功称为页面已更新,也不自行重启无关任务。 +`activationVerified:false` 仅表示该工具不核验启用,不表示启用失败,不能据此重复安装。用户已授权启用或试用时,先 `plugin_manager list_bundles`(沿 nextOffset 分页)按实际包名核对版本、error,再 `set_bundle`(target 为该包名、enabled:true),读取 application 和 nextStep。不猜 entryId、include: 前缀或以 set_plugin 代替;未获启用授权则留在工作台列表由用户操作。页面验收在当前已认证的宿主应用完成:Desktop 用运行中的 WebView,Web 用当前已登录应用页面;独立任务浏览器不自动共享宿主登录态,401 时停止该路径,不能探查/读取/复制 token、cookie、凭据或认证请求头绕过。无法操作宿主页面时保留具体“页面未验收”事实,不能以源码检查替代。 + 涉及数据结构时先验证迁移和恢复。恢复旧备份可能丢失新版本新增记录,不能自动覆盖;无法无损回退时保留新写入并采用向前修复。失败或结果未知时保留源码、需求及操作记录,先核对事实再恢复。 ## 继续开发指令需要携带的上下文 diff --git a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/startup-validation.md b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/startup-validation.md index e21ace2dca..7a4c7be9b2 100644 --- a/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/startup-validation.md +++ b/packages/plugins/skills/resources/skills/workdsh-workbench-dev/references/startup-validation.md @@ -31,6 +31,10 @@ content: { type: 'string', required: true } 安装字节核验、启用设置、Host 组件 active、对应版本工作台登记、Client 页面打开,是独立的检查。用户授权启用后核对所有状态,并实际完成本轮业务样例与重新打开后的数据保留检查。工具调用发生错误时保留具体错误与失败阶段,修复后重新构建新版本,不能用刷新、重启或聊天中的“已完成”替代修复。 +原生安装核验回执的 `activationVerified:false` 表示工具范围之外,不能推断启用失败或重新安装。已有启用/试用授权时按回执以 `list_bundles` 核对实际包名,再 `set_bundle`,从 application 和 nextStep 判断结果;没有授权则留在工作台列表。`set_plugin` 的 entryId 只能来自真实 list_plugins,不能拿包名或猜测 include: ID 代替 bundle 选择。 + +页面验收使用当前已认证的宿主应用:Desktop 为正在运行的 WebView,Web 为当前已登录应用页面。独立任务浏览器不自动共享宿主登录态,401 只说明该浏览器未获访问权限;停止此路径,返回已认证宿主页面或明确记录页面未验收。不得探查/读取/复制 token、cookie、凭据文件,或附加认证请求头绕过。实际宿主入口不可用时,报告入口、错误与未验收项,静态源码检查不补作页面验收。 + 隔离启动检查不能代替真实页面和业务验收,也不能保证 AI 永不生成错误;它负责尽早发现可复现的启动错误。 ## 平台自动包检查与运行检查 diff --git a/packages/plugins/skills/src/authoring/professional.ts b/packages/plugins/skills/src/authoring/professional.ts index cabcb8fe9b..35b1f4711c 100644 --- a/packages/plugins/skills/src/authoring/professional.ts +++ b/packages/plugins/skills/src/authoring/professional.ts @@ -23,7 +23,7 @@ export const professionalAuthoringSkills = [ { "name": "workdsh-workbench-dev", "description": "在官方 Creator 插件开发流程上补充 WorkDSH 业务 SDK、对象协作、身份和数据保护规范。", - "content": "# 工作台插件开发\n\n先通过宿主技能工具加载官方 `cordis-plugin-development`,在官方 Creator(创造模式,`cordis`)中使用原生会话、权限与工具链。本 Skill 只补充 WorkDSH 的业务约束;Host、Client、Loader、Slots、插件包和安装流程遵循官方 Skill,不另造开发 Agent、会话界面或安装器。\n\n再阅读 [需求定义](references/requirements.md) 和 [工作台产品体验](references/product-experience.md),从一句想法开始时先阅读 [需求梳理](references/intake.md),首次制作阅读 [首次开发](references/first-development.md),修改已有插件阅读 [继续调整](references/iteration.md)。用户本轮的范围、布局选择和暂停要求优先;这些文档不代表能力已经实现。\n\n## 共同规则\n\n1. 从“和 AI 一起梳理”进入且尚未确认需求时,先调用 `workbench_read_creation_request`,阅读 [需求梳理](references/intake.md),帮助用户明确目标并保存可编辑摘要,不生成源码、构建或安装。用户在左侧确认后,当前会话会关联正式需求与开发工程;已进入开发时先调用 `workbench_read_development_context`,读取当前原生会话实际关联的需求、源码、稳定插件身份、目标实例和候选包;不根据旧提示词猜测归属。先使用上下文 `sdk` 提供的已解析公开文档与类型;不要用 `Service.listService` 查询已知未收录的 WorkDSH 注册服务。官方 API 按官方 Skill 核对本轮用到的服务、事件、工具和 Slot,避免枚举无关接口。WorkDSH 扩展接口须按 [SDK 核验](references/sdk-verification.md) 单独核对:官方 Service 目录未收录不等于服务未运行,Config 的 `absent` 不等于插件未安装。WorkDSH 业务 SDK 和模板也以宿主实际交付的版本为准。不得猜测接口、复制私有实现或伪造回执;缺能力时保留源码和具体未完成项。\n2. 先定义业务对象、状态、用户动作、AI 分工和成果落点,再安排模块。项目关联是可选的;项目、资料、技能、专家、连接器使用各自公开服务和授权,不复制管理系统。一个包只声明一个工作台,后续保持包名与工作台 ID;本地制作不要求公开 GitHub 仓库。\n3. 保持用户已选布局,包括业务在左、原生会话在右的并排方式;不因套用模板强制重构。复用完整原生会话及其执行过程,不自制 Composer、聊天进度或工具轨迹。AI 通过真实业务工具读取当前对象、执行并写回候选或独立草稿,页面读取后供用户继续加工或采用;跳转聊天、复制提示词或回复结束都不等于完成业务协作。\n4. Excel、CSV 等文件复用系统 Office 的公开预览、编辑、保存和导出能力,不另写电子表格编辑器。业务页面负责字段对应、核对和统计;资料引用准确的资产及版本,统计由代码对完整已授权输入的固定修订确定性计算,不能拿截断预览作统计源。保存新文件版本后明确采用,历史分析与报告保留原快照。\n5. 对象、输入版本、会话和身份必须匹配;切换对象、账号或迭代后,旧回复不能写入新对象。保持现有源码、会话历史、用户草稿和业务数据,交接不能覆盖未发送的输入。企业请求只走 Host 已授权业务服务,不向页面提供通用带凭据代理;token、密码和密钥不进入页面、提示词、源码、日志、导出或插件包。\n6. 工作台默认按可持续使用的软件交付:围绕业务记录组织首页、列表、详情与返回路径,复杂任务使用应用内导航或页签,不把全部功能纵向堆成无限长页;多记录和页面数量根据本轮业务决定,用户明确要求的一次性工具除外。具体执行 [软件形态要求](references/product-experience.md)。页面使用中文业务字段,说明当前任务、结果与下一步,呈现正常、异常、空数据和失败恢复状态。业务插件不自行增加宿主一级菜单;平台统一通过原生“工作台”入口提供已安装应用及开发记录,不生成平级菜单。只使用实际已声明的官方公开插槽;不修改官方侧栏包、注入私有 DOM 或用缩进伪造菜单分组。平台提供的返回工作台和常用应用设置不在业务页重复实现;业务内部仍需提供返回模块或记录列表的路径,不能用浏览器返回代替。具体记录直达需要稳定对象标识和访问核对,不能拿会话链接冒充。局部修改沿用同一源码和插件身份。\n7. 通用安装使用官方 `plugin_manager` 的 `install_bundle` 及其真实回执,不手改 Profile、不复制安装器。在用户授权的安装阶段,以实际构建的 tgz 绝对路径为 target、`enabled:false` 安装候选,避免平台尚未核对归档时尝试注册。随后调用 `workbench_verify_native_installation`,仅传实际候选的 version、sha256;开发记录、源码和安装目标由宿主按原生会话归属核对。它核对实际安装字节并登记,不再次安装、不自动启用或重启。安装结果未知时先重新读取开发上下文并核对事实,不重复安装。核对后用户可在工作台列表启用;安装、登记、活动版本、Host/Client 注册和页面实际打开分别报告,更新需要重启时明确说明,不能自行重启其他任务。\n8. 依据用户本轮授权推进。要求先讨论时先明确工作路径;要求看效果时提供可交互效果并说明模拟范围;暂停打包时不自动构建或安装。生成代码、包检查通过、安装成功和页面可用分别报告;构建与 Slot 注册不能代替真实服务和用户操作验收。\n\n## 每轮交付\n\n保存需求修订、源码关联和修订、当前迭代、候选版本及摘要、目标安装实例、官方安装与平台登记的实际结果、业务验收和未完成项。候选包存在、安装字节匹配不证明它由当前源码修订生成,构建来源仍需实际证据。源码和需求文件提供恢复上下文;事实以当前运行时及持久记录为准。中断后先核对结果,不自动重复安装或创建会话。\n\n## 安装前的原生加载核对\n\nHost 入口必须在 `inject` 中声明实际使用的每项宿主服务,包括通过 `ctx.get(...)` 和 `ctx.storageDomain` 等属性取得的服务;例如工作台注册、存储、连接与身份服务。不能只声明工作台注册服务。服务可选时使用官方可选生命周期注入,不用未声明的读取绕过依赖检查。\n\n构建和 tgz 校验不证明插件可以启动。候选交付前,在隔离存储中通过官方 Cordis 插件生命周期加载 Host 入口,确认业务路由已注册、存储可打开、卸载可释放。直接调用 `apply(ctx)` 或仅导入模块不能代替这项检查,因为它们可能绕过官方注入约束。安装后分别检查真实字节、启用选择、Host 注册和 Client 页面;某组件失败时保留候选和数据,修正开发源码并增加版本号,再经正常更新流程验收,不修改已安装包文件。\n\n交付前执行 [构建后的启动检查](references/startup-validation.md),不要把打包成功视为应用启动成功。" + "content": "# 工作台插件开发\n\n先通过宿主技能工具加载官方 `cordis-plugin-development`,在官方 Creator(创造模式,`cordis`)中使用原生会话、权限与工具链。本 Skill 只补充 WorkDSH 的业务约束;Host、Client、Loader、Slots、插件包和安装流程遵循官方 Skill,不另造开发 Agent、会话界面或安装器。\n\n再阅读 [需求定义](references/requirements.md) 和 [工作台产品体验](references/product-experience.md),从一句想法开始时先阅读 [需求梳理](references/intake.md),首次制作阅读 [首次开发](references/first-development.md),修改已有插件阅读 [继续调整](references/iteration.md)。用户本轮的范围、布局选择和暂停要求优先;这些文档不代表能力已经实现。\n\n## 共同规则\n\n1. 从“和 AI 一起梳理”进入且尚未确认需求时,先调用 `workbench_read_creation_request`,阅读 [需求梳理](references/intake.md),帮助用户明确目标并保存可编辑摘要,不生成源码、构建或安装。用户在左侧确认后,当前会话会关联正式需求与开发工程;已进入开发时先调用 `workbench_read_development_context`,读取当前原生会话实际关联的需求、源码、稳定插件身份、目标实例和候选包;不根据旧提示词猜测归属。先使用上下文 `sdk` 提供的已解析公开文档与类型;不要用 `Service.listService` 查询已知未收录的 WorkDSH 注册服务。官方 API 按官方 Skill 核对本轮用到的服务、事件、工具和 Slot,避免枚举无关接口。WorkDSH 扩展接口须按 [SDK 核验](references/sdk-verification.md) 单独核对:官方 Service 目录未收录不等于服务未运行,Config 的 `absent` 不等于插件未安装。WorkDSH 业务 SDK 和模板也以宿主实际交付的版本为准。不得猜测接口、复制私有实现或伪造回执;缺能力时保留源码和具体未完成项。\n2. 先定义业务对象、状态、用户动作、AI 分工和成果落点,再安排模块。项目关联是可选的;项目、资料、技能、专家、连接器使用各自公开服务和授权,不复制管理系统。一个包只声明一个工作台,后续保持包名与工作台 ID;本地制作不要求公开 GitHub 仓库。\n3. 保持用户已选布局,包括业务在左、原生会话在右的并排方式;不因套用模板强制重构。复用完整原生会话及其执行过程,不自制 Composer、聊天进度或工具轨迹。AI 通过真实业务工具读取当前对象、执行并写回候选或独立草稿,页面读取后供用户继续加工或采用;跳转聊天、复制提示词或回复结束都不等于完成业务协作。\n4. Excel、CSV 等文件复用系统 Office 的公开预览、编辑、保存和导出能力,不另写电子表格编辑器。业务页面负责字段对应、核对和统计;资料引用准确的资产及版本,统计由代码对完整已授权输入的固定修订确定性计算,不能拿截断预览作统计源。保存新文件版本后明确采用,历史分析与报告保留原快照。\n5. 对象、输入版本、会话和身份必须匹配;切换对象、账号或迭代后,旧回复不能写入新对象。保持现有源码、会话历史、用户草稿和业务数据,交接不能覆盖未发送的输入。企业请求只走 Host 已授权业务服务,不向页面提供通用带凭据代理;token、密码和密钥不进入页面、提示词、源码、日志、导出或插件包。\n6. 工作台默认按可持续使用的软件交付:围绕业务记录组织首页、列表、详情与返回路径,复杂任务使用应用内导航或页签,不把全部功能纵向堆成无限长页;多记录和页面数量根据本轮业务决定,用户明确要求的一次性工具除外。具体执行 [软件形态要求](references/product-experience.md)。页面使用中文业务字段,说明当前任务、结果与下一步,呈现正常、异常、空数据和失败恢复状态。业务插件不自行增加宿主一级菜单;平台统一通过原生“工作台”入口提供已安装应用及开发记录,不生成平级菜单。只使用实际已声明的官方公开插槽;不修改官方侧栏包、注入私有 DOM 或用缩进伪造菜单分组。平台提供的返回工作台和常用应用设置不在业务页重复实现;业务内部仍需提供返回模块或记录列表的路径,不能用浏览器返回代替。具体记录直达需要稳定对象标识和访问核对,不能拿会话链接冒充。局部修改沿用同一源码和插件身份。\n7. 通用安装使用官方 `plugin_manager install_bundle`,以已核实 tgz 绝对路径、`enabled:false` 安装;再用候选 version、sha256 调用 `workbench_verify_native_installation` 核对字节并登记。`activationVerified:false` 只表示本工具未核验启用,绝不表示停用或启用失败,不因此重复安装。用户已授权启用或打开试用时,按回执 `list_bundles`(沿 nextOffset 分页)找到实际包名并核对版本、error,再 `set_bundle`,target 为该包名、enabled 为 true;读取 application、warnings、nextStep。不能猜 entryId、拼 include: 前缀或用 `set_plugin` 代替 bundle 选择;不能手改 Profile。不具备启用授权时,由用户在工作台列表启用。安装结果未知先核对事实;restart-required 如实报告,不自行重启其他任务。安装、登记、活动版本和页面可用分别报告。\n8. 依据用户本轮授权推进。要求先讨论时先明确工作路径;要求看效果时提供可交互效果并说明模拟范围;暂停打包时不自动构建或安装。生成代码、包检查通过、安装成功和页面可用分别报告;构建与 Slot 注册不能代替真实服务和用户操作验收。\n\n页面验收在当前已认证的宿主应用页面操作工作台:Desktop 使用正在运行的 WebView;Web 使用当前已登录应用页面。独立任务浏览器不自动共享宿主登录态,其 401 不能证明工作台失败。此时停止独立浏览器验收路径,返回已认证宿主页面或保留“页面未验收”,不得查找、读取或复制 token、cookie、凭据文件,或添加认证请求头绕过访问控制。源码和静态检查仍可继续,但不能冒充宿主页面操作。\n\n## 每轮交付\n\n保存需求修订、源码关联和修订、当前迭代、候选版本及摘要、目标安装实例、官方安装与平台登记的实际结果、业务验收和未完成项。候选包存在、安装字节匹配不证明它由当前源码修订生成,构建来源仍需实际证据。源码和需求文件提供恢复上下文;事实以当前运行时及持久记录为准。中断后先核对结果,不自动重复安装或创建会话。\n\n## 安装前的原生加载核对\n\nHost 入口必须在 `inject` 中声明实际使用的每项宿主服务,包括通过 `ctx.get(...)` 和 `ctx.storageDomain` 等属性取得的服务;例如工作台注册、存储、连接与身份服务。不能只声明工作台注册服务。服务可选时使用官方可选生命周期注入,不用未声明的读取绕过依赖检查。\n\n构建和 tgz 校验不证明插件可以启动。候选交付前,在隔离存储中通过官方 Cordis 插件生命周期加载 Host 入口,确认业务路由已注册、存储可打开、卸载可释放。直接调用 `apply(ctx)` 或仅导入模块不能代替这项检查,因为它们可能绕过官方注入约束。安装后分别检查真实字节、启用选择、Host 注册和 Client 页面;某组件失败时保留候选和数据,修正开发源码并增加版本号,再经正常更新流程验收,不修改已安装包文件。\n\n交付前执行 [构建后的启动检查](references/startup-validation.md),不要把打包成功视为应用启动成功。" } ] as const; export const skillCreatorContent: string = "# Skill Creator\n\nThis skill provides guidance for creating effective skills.\n\nRead [DSH authoring adaptation](references/dsh-authoring.md) for installation, available lifecycle tools and update boundaries. Read [bundle production](references/bundle-production.md) when executing the bundled scripts. These platform notes supplement the creation process below.\n\n## About Skills\n\nSkills are modular, self-contained packages that extend WorkDSH's capabilities by providing\nspecialized knowledge, workflows, and tools. Think of them as \"onboarding guides\" for specific\ndomains or tasks—they transform WorkDSH from a general-purpose agent into a specialized agent\nequipped with procedural knowledge that no model can fully possess.\n\n### What Skills Provide\n\n1. Specialized workflows - Multi-step procedures for specific domains\n2. Tool integrations - Instructions for working with specific file formats or APIs\n3. Domain expertise - Company-specific knowledge, schemas, business logic\n4. Bundled resources - Scripts, references, and assets for complex and repetitive tasks\n\n### Anatomy of a Skill\n\nEvery skill consists of a required SKILL.md file and optional bundled resources:\n\n```\nskill-name/\n├── SKILL.md (required)\n│ ├── YAML frontmatter metadata (required)\n│ │ ├── name: (required)\n│ │ └── description: (required)\n│ └── Markdown instructions (required)\n└── Bundled Resources (optional)\n ├── scripts/ - Executable code (Python/Bash/etc.)\n ├── references/ - Documentation intended to be loaded into context as needed\n └── assets/ - Files used in output (templates, icons, fonts, etc.)\n```\n\n#### SKILL.md (required)\n\n**Metadata Quality:** The `name` and `description` in YAML frontmatter determine when WorkDSH will use the skill. Be specific about what the skill does and when to use it. Use the third-person (e.g. \"This skill should be used when...\" instead of \"Use this skill when...\").\n\n#### Bundled Resources (optional)\n\n##### Scripts (`scripts/`)\n\nExecutable code (Python/Bash/etc.) for tasks that require deterministic reliability or are repeatedly rewritten.\n\n- **When to include**: When the same code is being rewritten repeatedly or deterministic reliability is needed\n- **Example**: `scripts/rotate_pdf.py` for PDF rotation tasks\n- **Benefits**: Token efficient, deterministic, may be executed without loading into context\n- **Note**: Scripts may still need to be read by WorkDSH for patching or environment-specific adjustments\n\n##### References (`references/`)\n\nDocumentation and reference material intended to be loaded as needed into context to inform WorkDSH's process and thinking.\n\n- **When to include**: For documentation that WorkDSH should reference while working\n- **Examples**: `references/finance.md` for financial schemas, `references/mnda.md` for company NDA template, `references/policies.md` for company policies, `references/api_docs.md` for API specifications\n- **Use cases**: Database schemas, API documentation, domain knowledge, company policies, detailed workflow guides\n- **Benefits**: Keeps SKILL.md lean, loaded only when WorkDSH determines it's needed\n- **Best practice**: If files are large (>10k words), include grep search patterns in SKILL.md\n- **Avoid duplication**: Information should live in either SKILL.md or references files, not both. Prefer references files for detailed information unless it's truly core to the skill—this keeps SKILL.md lean while making information discoverable without hogging the context window. Keep only essential procedural instructions and workflow guidance in SKILL.md; move detailed reference material, schemas, and examples to references files.\n\n##### Assets (`assets/`)\n\nFiles not intended to be loaded into context, but rather used within the output WorkDSH produces.\n\n- **When to include**: When the skill needs files that will be used in the final output\n- **Examples**: `assets/logo.png` for brand assets, `assets/slides.pptx` for PowerPoint templates, `assets/frontend-template/` for HTML/React boilerplate, `assets/font.ttf` for typography\n- **Use cases**: Templates, images, icons, boilerplate code, fonts, sample documents that get copied or modified\n- **Benefits**: Separates output resources from documentation, enables WorkDSH to use files without loading them into context\n\n### Progressive Disclosure Design Principle\n\nSkills use a three-level loading system to manage context efficiently:\n\n1. **Metadata (name + description)** - Always in context (~100 words)\n2. **SKILL.md body** - When skill triggers (<5k words)\n3. **Bundled resources** - As needed by WorkDSH (Unlimited*)\n\n*Unlimited because scripts can be executed without reading into context window.\n\n## Skill Creation Process\n\nTo create a skill, follow the \"Skill Creation Process\" in order, skipping steps only if there is a clear reason why they are not applicable.\n\n### Step 1: Understanding the Skill with Concrete Examples\n\nSkip this step only when the skill's usage patterns are already clearly understood. It remains valuable even when working with an existing skill.\n\nTo create an effective skill, clearly understand concrete examples of how the skill will be used. This understanding can come from either direct user examples or generated examples that are validated with user feedback.\n\nFor example, when building an image-editor skill, relevant questions include:\n\n- \"What functionality should the image-editor skill support? Editing, rotating, anything else?\"\n- \"Can you give some examples of how this skill would be used?\"\n- \"I can imagine users asking for things like 'Remove the red-eye from this image' or 'Rotate this image'. Are there other ways you imagine this skill being used?\"\n- \"What would a user say that should trigger this skill?\"\n\nTo avoid overwhelming users, avoid asking too many questions in a single message. Start with the most important questions and follow up as needed for better effectiveness.\n\nConclude this step when there is a clear sense of the functionality the skill should support.\n\n### Step 2: Planning the Reusable Skill Contents\n\nTo turn concrete examples into an effective skill, analyze each example by:\n\n1. Considering how to execute on the example from scratch\n2. Identifying what scripts, references, and assets would be helpful when executing these workflows repeatedly\n\nExample: When building a `pdf-editor` skill to handle queries like \"Help me rotate this PDF,\" the analysis shows:\n\n1. Rotating a PDF requires re-writing the same code each time\n2. A `scripts/rotate_pdf.py` script would be helpful to store in the skill\n\nExample: When designing a `frontend-webapp-builder` skill for queries like \"Build me a todo app\" or \"Build me a dashboard to track my steps,\" the analysis shows:\n\n1. Writing a frontend webapp requires the same boilerplate HTML/React each time\n2. An `assets/hello-world/` template containing the boilerplate HTML/React project files would be helpful to store in the skill\n\nExample: When building a `big-query` skill to handle queries like \"How many users have logged in today?\" the analysis shows:\n\n1. Querying BigQuery requires re-discovering the table schemas and relationships each time\n2. A `references/schema.md` file documenting the table schemas would be helpful to store in the skill\n\nTo establish the skill's contents, analyze each concrete example to create a list of the reusable resources to include: scripts, references, and assets.\n\n### Step 3: Initializing the Skill\n\nAt this point, it is time to actually create the skill.\n\nSkip this step only if the skill being developed already exists, and iteration or packaging is needed. In this case, continue to the next step.\n\nInitialize only in a workspace draft directory, never directly in an official installed skill root. Provide an explicit output directory:\n\n```bash\npython3 /scripts/init_skill.py --path /skill-drafts\n```\n\nUse the actual resourceBase path exposed by Harness, not the literal placeholder above. If shell/Python is unavailable, explain the execution gap; do not claim files were generated.\n\nThe script:\n\n- Creates the skill directory at the specified path\n- Generates a SKILL.md template with proper frontmatter and TODO placeholders\n- Creates example resource directories: `scripts/`, `references/`, and `assets/`\n- Adds example files in each directory that can be customized or deleted\n\nAfter initialization, customize or remove the generated SKILL.md and example files as needed.\n\n### Step 4: Edit the Skill\n\nWhen editing the (newly-generated or existing) skill, remember that the skill is being created for another instance of WorkDSH to use. Focus on including information that would be beneficial and non-obvious to WorkDSH. Consider what procedural knowledge, domain-specific details, or reusable assets would help another WorkDSH instance execute these tasks more effectively.\n\n#### Start with Reusable Skill Contents\n\nTo begin implementation, start with the reusable resources identified above: `scripts/`, `references/`, and `assets/` files. Note that this step may require user input. For example, when implementing a `brand-guidelines` skill, the user may need to provide brand assets or templates to store in `assets/`, or documentation to store in `references/`.\n\nAlso, delete any example files and directories not needed for the skill. The initialization script creates example files in `scripts/`, `references/`, and `assets/` to demonstrate structure, but most skills won't need all of them.\n\n#### Update SKILL.md\n\n**Writing Style:** Write the entire skill using **imperative/infinitive form** (verb-first instructions), not second person. Use objective, instructional language (e.g., \"To accomplish X, do Y\" rather than \"You should do X\" or \"If you need to do X\"). This maintains consistency and clarity for AI consumption.\n\nTo complete SKILL.md, answer the following questions:\n\n1. What is the purpose of the skill, in a few sentences?\n2. When should the skill be used?\n3. In practice, how should WorkDSH use the skill? All reusable skill contents developed above should be referenced so that WorkDSH knows how to use them.\n\n### Step 5: Packaging a Skill\n\nOnce the skill is ready, it should be packaged into a distributable zip file that gets shared with the user. The packaging process automatically validates the skill first to ensure it meets all requirements:\n\n```bash\npython3 /scripts/package_skill.py \n```\n\nOptional output directory specification:\n\n```bash\npython3 /scripts/package_skill.py ./dist\n```\n\nThe packaging script will:\n\n1. **Validate** the skill automatically, checking:\n - Basic frontmatter boundaries and required fields (official Harness parsing remains the installation gate)\n - Skill naming conventions and directory structure\n - Description presence and basic character restrictions (semantic routing quality requires review)\n - File organization, unresolved placeholders and relative Markdown resource references\n\n2. **Package** the skill if validation passes, creating a zip file named after the skill (e.g., `my-skill.zip`) that includes all files and maintains the proper directory structure for distribution.\n\nIf validation fails, the script will report the errors and exit without creating a package. Fix any validation errors and run the packaging command again.\n\n### Step 6: Iterate\n\nAfter testing the skill, users may request improvements. Often this happens right after using the skill, with fresh context of how the skill performed.\n\n**Iteration workflow:**\n1. Use the skill on real tasks\n2. Notice struggles or inefficiencies\n3. Identify how SKILL.md or bundled resources should be updated\n4. Implement changes and test again\n\n## Updating an Existing Skill\n\nRead the existing SKILL.md and relevant resources. Preserve company rules and supplied assets. Use WorkDSH management operations for installed content; never shell-write official skill roots. For a complete bundle update, prepare a separate draft, compare its content with the installed version, export a verified ZIP, and use the dedicated import flow. Same-name imports currently reject collisions: explain the replacement procedure and preserve the original before any user-authorized uninstall. Do not write WorkBuddy market metadata or promise automatic update protection that WorkDSH does not implement."; diff --git a/packages/plugins/workbenches/README.md b/packages/plugins/workbenches/README.md index 6159f18766..f9aa81dccf 100644 --- a/packages/plugins/workbenches/README.md +++ b/packages/plugins/workbenches/README.md @@ -164,7 +164,7 @@ WorkDSH 注册服务是本平台扩展,不属于官方预生成的 Service 接 平台通过公开 `tools/execute` 事件观测本插件已核对归属的原生开发会话,按实际 SDK 文件读取、源码文件写入、共用交付命令、原生安装及安装核对分类。原始命令、参数、输出、凭据和文件内容不进入计时记录。身份、需求修订和迭代在读写前重新核对;宿主重启后未结束的调用标为中断,不补造完成时间。诊断故障不会改变原生工具权限和返回结果。 -开发页“制作计时”每五秒读取当前迭代记录,显示累计工具执行耗时、已观测工具首尾跨度及中断状态。它不是完整模型思考时间或用户授权等待时间,也不是应用交付回执;没有工具事件的旧记录显示尚未观测。安装发生在其他会话、手动写源码或未识别的命令时不能声称已计入。每轮最多1000条,达到上限明确提示统计不完整。 +阶段遥测仅用于测试和分析 AI 制作效率,不在工作台产品界面展示或轮询。Host 保留当前迭代的工具执行耗时、已观测工具首尾跨度和中断状态;这些记录不包含完整模型思考时间,也不是应用交付回执。没有工具事件的旧记录不补造耗时;安装发生在其他会话、手动写源码或未识别的命令时不能声称已计入。每轮最多1000条,达到上限时统计不完整。 启动检查还核对直接运行依赖与 peer 的实际安装版本,不把“模块能解析”当成满足版本范围;缺少明确 optional 的 peer 不阻断。无法按 SemVer 核对的来源明确失败,诊断不回显可能带凭据的版本 URL。这仍不等于完整传递依赖闭包证明。 diff --git a/packages/plugins/workbenches/src/client/WorkbenchDelivery.tsx b/packages/plugins/workbenches/src/client/WorkbenchDelivery.tsx index 632a92d06a..3af1453362 100644 --- a/packages/plugins/workbenches/src/client/WorkbenchDelivery.tsx +++ b/packages/plugins/workbenches/src/client/WorkbenchDelivery.tsx @@ -1,4 +1,3 @@ -import {WorkbenchTiming} from './WorkbenchTiming.js'; import React,{useEffect,useMemo,useRef,useState}from'react'; import{createWorkbenchCatalogClient,type WorkbenchCatalogEntry}from'./catalog-client.js'; import{createWorkbenchActivationClient}from'./activation-client.js'; @@ -36,5 +35,5 @@ export function WorkbenchDelivery({entry,criteria,refreshToken,canOpen,onOpen,on try{const result=await review(snapshot.review.binding,lifetime.current.signal);setSnapshot(old=>old?{...old,review:result}:old);setChecked(false);setNotice('已保存你的验收确认。此版本可从工作台打开日常使用。');} catch{setError('验收未保存:版本、需求或运行状态可能已变化,请重新核对后再确认。');}finally{operation.current=false;setBusy(false);void refresh();} } - return

当前进度{snapshot?.row?.installed?` · ${snapshot.row.installed.version}`:''}

{state?.title??(error?'交付状态待核对':'正在核对交付状态…')}

{state?.description??'正在读取安装、启用、注册和验收记录。'}

{snapshot?.row?.installed&&snapshot.count>0&&

当前进度针对已安装版本;刚完成修改时,请在“查看版本与安装”核对新的开发产物。

}{snapshot&&
  1. 安装文件:{snapshot.row?.installed?'已核验':'待安装'}
  2. 应用启动:{snapshot.row?.runtimeStatus==='failed'?'失败':snapshot.row?.runtimeStatus==='active'&&snapshot.row.registered?'已就绪':'待核对'}
  3. 页面入口:{snapshot.row?.descriptor&&canOpen?.(snapshot.row.descriptor.id)?'已就绪,待试用':'待核对'}
  4. 试用验收:{snapshot.review.accepted?'此版本已确认':'待用户确认'}
}{error&&

{error}

}{notice&&

{notice}

}{submissionMessage&&

{submissionMessage}

}{submissionError&&

{submissionError}

}
{snapshot&&}
{completionCriteria&&
本轮完成标准

{completionCriteria}

逐项实际操作,并核对未实现项;AI 的完成声明不能代替试用。

}{ready&&!snapshot?.review.accepted&&
}{snapshot?.review.accepted&&

验收确认时间:{new Date(snapshot.review.accepted.acceptedAt).toLocaleString()}

}{!ready&&

应用尚未通过试用验收。需求不符或已知限制需记录并处理。

}
; + return

当前进度{snapshot?.row?.installed?` · ${snapshot.row.installed.version}`:''}

{state?.title??(error?'交付状态待核对':'正在核对交付状态…')}

{state?.description??'正在读取安装、启用、注册和验收记录。'}

{snapshot?.row?.installed&&snapshot.count>0&&

当前进度针对已安装版本;刚完成修改时,请在“查看版本与安装”核对新的开发产物。

}{snapshot&&
  1. 安装文件:{snapshot.row?.installed?'已核验':'待安装'}
  2. 应用启动:{snapshot.row?.runtimeStatus==='failed'?'失败':snapshot.row?.runtimeStatus==='active'&&snapshot.row.registered?'已就绪':'待核对'}
  3. 页面入口:{snapshot.row?.descriptor&&canOpen?.(snapshot.row.descriptor.id)?'已就绪,待试用':'待核对'}
  4. 试用验收:{snapshot.review.accepted?'此版本已确认':'待用户确认'}
}{error&&

{error}

}{notice&&

{notice}

}{submissionMessage&&

{submissionMessage}

}{submissionError&&

{submissionError}

}
{snapshot&&}
{completionCriteria&&
本轮完成标准

{completionCriteria}

逐项实际操作,并核对未实现项;AI 的完成声明不能代替试用。

}{ready&&!snapshot?.review.accepted&&
}{snapshot?.review.accepted&&

验收确认时间:{new Date(snapshot.review.accepted.acceptedAt).toLocaleString()}

}{!ready&&

应用尚未通过试用验收。需求不符或已知限制需记录并处理。

}
; } diff --git a/packages/plugins/workbenches/src/client/WorkbenchTiming.tsx b/packages/plugins/workbenches/src/client/WorkbenchTiming.tsx deleted file mode 100644 index c4f8586650..0000000000 --- a/packages/plugins/workbenches/src/client/WorkbenchTiming.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React,{useEffect,useState} from 'react'; -const labels={sdk:'SDK 查阅',coding:'代码写入',delivery:'构建与检查命令',installation:'安装与核对',preparation:'开发准备',approval:'等待原生工具授权'}; -type Category=keyof typeof labels; -type Event={category:Category;startedAt:string;finishedAt?:string;durationMs?:number;status:'running'|'passed'|'failed'|'interrupted'}; -type Report={measurement:'tool-dispatch-only';events:Event[];limitReached:boolean}; -const seconds=(ms:number)=>ms>0&&ms<100?'<0.1 秒':`${(ms/1000).toFixed(1)} 秒`; -export function WorkbenchTiming({developmentId,iterationId}:{developmentId:string;iterationId:string}){ - const [report,setReport]=useState(),[unavailable,setUnavailable]=useState(false); - useEffect(()=>{let active=true,busy=false;const controller=new AbortController();setReport(undefined);setUnavailable(false); - const refresh=async()=>{if(busy)return;busy=true;try{const response=await fetch('/api/workdsh-workbench-timing',{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify({developmentId}),signal:AbortSignal.any([controller.signal,AbortSignal.timeout(10000)])});const value=await response.json();if(!response.ok||value.ok!==true||value.value?.measurement!=='tool-dispatch-only'||!Array.isArray(value.value.events))throw Error();if(active){setReport(value.value);setUnavailable(false);}}catch{if(active)setUnavailable(true);}finally{busy=false;}}; - void refresh();const timer=setInterval(()=>void refresh(),5000);return()=>{active=false;controller.abort();clearInterval(timer);}; - },[developmentId,iterationId]); - const latest=report?.events.at(-1); - const start=report?.events.length?Math.min(...report.events.map(e=>Date.parse(e.startedAt))):undefined; - const end=report?.events.length?Math.max(...report.events.map(e=>Date.parse(e.finishedAt??e.startedAt))):undefined; - return
制作计时{latest?` · ${labels[latest.category]}${latest.status==='running'?'执行中':latest.status==='interrupted'?'已中断':latest.status==='failed'?'未完成':'已记录'}`:''} - {start!==undefined&&end!==undefined&&

已观测工具首尾跨度:{seconds(Math.max(0,end-start))}。这是已发生工具之间的时间,尚非整轮完成回执。

} -

记录原生工具实际执行耗时,模型思考不计入工具用时;原生工具明确请求授权时单独记录等待;并行调用的累计用时不等于总制作时间。

- {unavailable?

计时暂不可读取,不影响开发;不会显示为零耗时或完成。

:!report?.events.length?

本轮尚无计时记录。旧版本已发生的开发不会补造耗时。

:
    {(Object.keys(labels) as Category[]).map(type=>{const rows=report.events.filter(e=>e.category===type);return
  • {labels[type]}:{rows.length?`${seconds(rows.reduce((sum,e)=>sum+(e.durationMs??0),0))} · ${rows.length} 次${rows.some(e=>e.status==='running')?',执行中':''}${rows.some(e=>e.status==='interrupted')?',含中断记录':''}${rows.some(e=>e.status==='failed')?',含失败调用':''}`:'尚未观测'}
  • ;})}
} - {report?.limitReached&&

已达到本轮计时记录上限,统计不完整。

} -
; -} diff --git a/packages/plugins/workbenches/src/runtime/development-tools.ts b/packages/plugins/workbenches/src/runtime/development-tools.ts index 2dc18d8a63..9943be2573 100644 --- a/packages/plugins/workbenches/src/runtime/development-tools.ts +++ b/packages/plugins/workbenches/src/runtime/development-tools.ts @@ -61,11 +61,11 @@ export function registerWorkbenchDevelopmentTools(ctx:Context,api:WorkbenchDevel })); ctx.tools.register(defineTool({ name:'workbench_verify_native_installation', - description:'原生 plugin_manager install_bundle 安装后,核对本会话关联工作台的实际安装字节、版本和候选包并登记。首次原生安装使用 enabled:false,核对后用户可在工作台列表启用。参数仅接受实际候选包的版本和SHA256,不能传开发记录、包名或文件路径。实际安装、平台登记、活动版本与页面打开是不同事实;本工具不替代启用、重启或业务验收。保持原生权限,不能用模型完成声明替代安装结果。', + description:'原生 plugin_manager install_bundle 安装后,核对本会话关联工作台的实际安装字节、版本和候选包并登记。参数仅接受实际候选版本和SHA256。activationVerified:false 仅表示本工具不核验启用,并非停用或失败,不能据此重复安装。用户已授权启用时按回执先 list_bundles 核对实际包名,再 set_bundle;不要猜 entryId 或用 set_plugin。未获启用授权则留在工作台列表。页面与业务需在当前已认证宿主应用验收:Desktop 用运行中的 WebView,Web 用当前已登录页面;独立任务浏览器401不代表应用失败,不得探查或复制凭据绕过。', parameters:{version:{type:'string',required:true,description:'实际候选包的完整、规范SemVer版本。'},sha256:{type:'string',required:true,description:'实际tgz字节的64位小写SHA256。'}}, output:output('工作台安装核对结果'), presentCall:()=>({card:'generic',title:'核对工作台安装',kind:'read'}), - presentResult:(_args,result)=>({card:'generic',title:result.isError?'工作台安装核对未完成':'工作台安装核对结果',...(!result.isError?{content:[{type:'text' as const,text:'已核对实际安装结果;启用、重启与页面验收需分别确认。'}]}:{})}), + presentResult:(_args,result)=>({card:'generic',title:result.isError?'工作台安装核对未完成':'工作台安装核对结果',...(!result.isError?{content:[{type:'text' as const,text:'安装字节已核对并登记;启用状态尚未核验,不表示启用失败。启用、重启与宿主页面验收需分别确认。'}]}:{})}), async execute(args,exec){ allowed(args,['version','sha256']); if(typeof args.version!=='string'||args.version.length>256||!exactVersion.test(args.version)||semver.valid(args.version)===null|| diff --git a/packages/plugins/workbenches/src/runtime/native-installation-handoff.ts b/packages/plugins/workbenches/src/runtime/native-installation-handoff.ts new file mode 100644 index 0000000000..23cf32d104 --- /dev/null +++ b/packages/plugins/workbenches/src/runtime/native-installation-handoff.ts @@ -0,0 +1,20 @@ +/** A byte-verification receipt is deliberately silent about runtime activation. + * Exact public operations below are conditional guidance, never authorization. */ +export function nativeWorkbenchInstallationHandoff(packageName:string){ + return { + activation:{status:'not-checked' as const,reason:'verification-only' as const, + explanation:'activationVerified:false 只表示本工具未核验启用状态,不表示应用已停用或启用失败。安装字节已核对并登记,不要因此重复安装。'}, + clientPage:{status:'not-checked' as const,requiredSurface:'authenticated-host-application' as const}, + nextStep:{ + ifActivationAuthorized:{ + inspect:{tool:'plugin_manager',arguments:{action:'list_bundles',offset:0,limit:100}}, + matchPackageName:packageName, + enable:{tool:'plugin_manager',arguments:{action:'set_bundle',target:packageName,enabled:true}}, + instruction:'仅在用户已授权启用或打开试用时执行:先按 nextOffset 分页找到同名 bundle,核对版本和 error;再按实际包名 set_bundle。读取 application 与 nextStep,restart-required 时如实报告并保留其他任务。不要猜 entryId、include: 前缀或改用 set_plugin;不要重复 install_bundle。', + }, + ifActivationNotAuthorized:'保留已安装、已登记状态,由用户在工作台列表启用并打开。', + pageAcceptance:'启用后,在当前已认证的宿主应用页面打开工作台:Desktop 使用正在运行的 WebView,Web 使用当前已登录应用页面。操作已保存验收样例并重开核对数据;未操作的页面和业务仍标为未验收。', + authenticationBoundary:'独立任务浏览器不自动共享宿主应用登录态。遇到 401 时停止该验收路径,记录页面未验收并返回当前已认证宿主页面;不得探查、复制 token、cookie、凭据或加认证请求头绕过。', + }, + }; +} diff --git a/packages/plugins/workbenches/src/runtime/native-installation-host.ts b/packages/plugins/workbenches/src/runtime/native-installation-host.ts index c9341e75d6..be15f4d370 100644 --- a/packages/plugins/workbenches/src/runtime/native-installation-host.ts +++ b/packages/plugins/workbenches/src/runtime/native-installation-host.ts @@ -18,6 +18,7 @@ import { receiveWorkbenchArtifact } from './upload-artifact.js'; import { inspectWorkbenchPackageArtifact } from './package-artifact.js'; import { validateWorkbenchPackageLayout } from './package-layout.js'; import { readWorkbenchRequestBody } from './request-body.js'; +import {nativeWorkbenchInstallationHandoff} from './native-installation-handoff.js'; /** Reconcile a native package-manager installation. This owner never installs, * enables, reloads, or trusts a model-provided installation receipt. */ @@ -101,7 +102,8 @@ export async function mountNativeWorkbenchInstallationHost(ctx: Context, directo }, { waitMs: 10_000 }); await guard(); return Object.freeze({ developmentId: baseline.id, iterationId: baseline.iterationId, package: candidate.package, - descriptor: candidate.descriptor, installedVerified: true as const, activationVerified: false as const }); + descriptor: candidate.descriptor, installedVerified: true as const, activationVerified: false as const, + ...nativeWorkbenchInstallationHandoff(baseline.packageName) }); } const api = { async readContext(sessionId: string, signal?: AbortSignal) { diff --git a/packages/plugins/workbenches/tests/development-tools.test.mjs b/packages/plugins/workbenches/tests/development-tools.test.mjs index 9349bba4d9..162bc4e555 100644 --- a/packages/plugins/workbenches/tests/development-tools.test.mjs +++ b/packages/plugins/workbenches/tests/development-tools.test.mjs @@ -50,6 +50,24 @@ test('official registry returns actual Host facts using only Agent Session and f assert.equal(presentation.content[0].text.includes('restart-required'),false,'native labels use business language while canonical output retains actual facts'); }); +test('official verification tool distinguishes unchecked activation and authenticated host acceptance without granting management access',async t=>{ + const f=await fixture(t),tool=f.tool('workbench_verify_native_installation'); + const handoff=await build({entryPoints:[fileURLToPath(new URL('../src/runtime/native-installation-handoff.ts',import.meta.url))],bundle:true,platform:'node',format:'esm',write:false}); + const {nativeWorkbenchInstallationHandoff}=await import(`data:text/javascript;base64,${Buffer.from(handoff.outputFiles[0].text).toString('base64')}`); + f.values.installation={installedVerified:true,activationVerified:false,...nativeWorkbenchInstallationHandoff('actual-owned-package')}; + const value=await f.call('workbench_verify_native_installation',{version:'1.0.0',sha256:'a'.repeat(64)}); + assert.deepEqual(value,f.values.installation); + assert.equal(value.activation.status,'not-checked');assert.equal(value.clientPage.status,'not-checked'); + assert.equal(value.clientPage.requiredSurface,'authenticated-host-application'); + assert.match(value.nextStep.pageAcceptance,/Desktop.*WebView.*Web.*当前已登录/); + assert.equal(value.nextStep.ifActivationAuthorized.enable.arguments.target,'actual-owned-package'); + assert.equal(value.nextStep.ifActivationAuthorized.enable.arguments.action,'set_bundle'); + assert.match(value.nextStep.ifActivationAuthorized.instruction,/仅在用户已授权/); + assert.match(tool.description,/activationVerified:false.*并非停用或失败/); + assert.match(tool.description,/不得探查或复制凭据/); + assert.deepEqual(f.calls.map(row=>row[0]),['verify'],'receipt guidance never dispatches an installation or activation'); +}); + test('missing Agent and model-chosen identity/path arguments cannot invoke Host APIs',async t=>{ const f=await fixture(t),valid={version:'1.0.0',sha256:'a'.repeat(64)}; await assert.rejects(f.call('workbench_read_development_context',{},f.exec(null)),/session-required/); diff --git a/packages/plugins/workbenches/tests/native-installation-client.test.mjs b/packages/plugins/workbenches/tests/native-installation-client.test.mjs index e2bf1203dd..52a6321a9a 100644 --- a/packages/plugins/workbenches/tests/native-installation-client.test.mjs +++ b/packages/plugins/workbenches/tests/native-installation-client.test.mjs @@ -13,10 +13,11 @@ test('native reconciliation sends only the selected baseline and never claims ac assert.equal(path,'/api/workdsh-workbench-native-installation'); assert.deepEqual(JSON.parse(options.body),{developmentId:'dev',expectedRevision:'r1',version:'1.0.0',sha256:selection.sha256}); assert.equal(options.credentials,'same-origin'); - return Response.json({ok:true,value:{...value,path:'/private',package:{...value.package,path:'/private'}}}); + return Response.json({ok:true,value:{...value,path:'/private',package:{...value.package,path:'/private'},activation:{status:'not-checked',reason:'verification-only'},clientPage:{status:'not-checked'},nextStep:{ifActivationAuthorized:{enable:{tool:'plugin_manager',arguments:{action:'set_bundle',target:selection.packageName,enabled:true}}}}}}); })({...selection,path:'/never-send'}); assert.equal(result.installedVerified,true);assert.equal(result.activationVerified,false); assert.equal(result.path,undefined);assert.equal(result.package.path,undefined);assert.ok(Object.isFrozen(result.package)); + assert.equal(result.nextStep,undefined,'the compatibility client does not execute or promote conditional management guidance'); }); test('native reconciliation rejects foreign receipts, activation claims, failed HTTP and late responses',async()=>{ for(const bad of [{...value,developmentId:'foreign'},{...value,package:{...value.package,sha256:'b'.repeat(64)}},{...value,package:{...value.package,version:'2.0.0'}},{...value,descriptor:{...descriptor,id:'foreign.id'}},{...value,activationVerified:true},{...value,installedVerified:false}]) diff --git a/packages/plugins/workbenches/tests/native-installation-host.test.mjs b/packages/plugins/workbenches/tests/native-installation-host.test.mjs index 53125bba1a..e21f81113b 100644 --- a/packages/plugins/workbenches/tests/native-installation-host.test.mjs +++ b/packages/plugins/workbenches/tests/native-installation-host.test.mjs @@ -161,6 +161,16 @@ test('native installation reconciles actual bytes under bound development owners assert.deepEqual(result.package, f.artifact.receipt); assert.equal(result.installedVerified, true); assert.equal(result.activationVerified, false); + assert.equal(result.activation.status,'not-checked');assert.equal(result.activation.reason,'verification-only'); + assert.match(result.activation.explanation,/不表示.*启用失败/); + assert.deepEqual(result.nextStep.ifActivationAuthorized.inspect,{tool:'plugin_manager',arguments:{action:'list_bundles',offset:0,limit:100}}); + assert.deepEqual(result.nextStep.ifActivationAuthorized.enable,{tool:'plugin_manager',arguments:{action:'set_bundle',target:f.packageName,enabled:true}}); + assert.equal(result.nextStep.ifActivationAuthorized.matchPackageName,f.packageName); + assert.match(result.nextStep.ifActivationNotAuthorized,/用户在工作台列表/); + assert.equal(result.clientPage.status,'not-checked');assert.equal(result.clientPage.requiredSurface,'authenticated-host-application'); + assert.match(result.nextStep.pageAcceptance,/Desktop.*WebView.*Web.*当前已登录/); + assert.match(result.nextStep.authenticationBoundary,/401/);assert.match(result.nextStep.authenticationBoundary,/不得探查、复制/); + assert.deepEqual(result,JSON.parse(JSON.stringify(result)),'extended receipt remains lossless official Tool JSON'); assert.equal(f.flags.sideEffects, 0); assert.equal(await readFile(join(f.directory, 'package.json'), 'utf8'), beforeProfile); }); From 8a93ad097ab78c6cc84cecd5e85ca6703b24c76d Mon Sep 17 00:00:00 2001 From: techflag <562635045@qq.com> Date: Sun, 18 Oct 2026 13:45:54 +0800 Subject: [PATCH 7/7] Document shared delivery fixes and third-party upgrade compatibility --- .github/release-notes/desktop-v3.0.0-alpha.1.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/release-notes/desktop-v3.0.0-alpha.1.md b/.github/release-notes/desktop-v3.0.0-alpha.1.md index 2efb3b0135..13bb287981 100644 --- a/.github/release-notes/desktop-v3.0.0-alpha.1.md +++ b/.github/release-notes/desktop-v3.0.0-alpha.1.md @@ -9,10 +9,11 @@ - Desktop 与 Web 固定到同一 DSH 版本。官方运行依赖从校验过的发行文件提取,桌面端继续使用完整官方客户端。 - 桌面启动失败时显示实际原因与脱敏后的近期运行输出,并保留本机错误记录;进程清理失败不会再覆盖启动原因。 - 新工作区在默认本机端口被占用时可选择独立端口;已有工作区保留原地址,避免页面数据因地址变化而看似丢失。错误弹框显示简短原因,完整脱敏输出保存在本机日志中。 -- 基础 Desktop Profile 加入工作台平台,与连接器、专家、资料库、项目和 Skill 共六个默认 WorkDSH bundle。工作台统一需求确认、开发、启动检查、安装状态核对与试用验收,记录阶段耗时,支持中断后恢复。 +- 基础 Desktop Profile 加入工作台平台,与连接器、专家、资料库、项目和 Skill 共六个默认 WorkDSH bundle。工作台统一需求确认、开发、启动检查、安装状态核对与试用验收,支持中断后恢复。 - 视频展示使用平台生成的旅行应用,可持续管理多次旅行,每次有独立行程、预算和清单;基础安装包不预装该业务示例。 - 改进发行脚本的首次依赖安装顺序,检查 Profile 依赖、平台运行文件与归档完整性。 - 共用交付脚本使用随包 Node 和 pnpm,不依赖系统 npm;构建不自动切换包管理器或安装依赖,打包不重复执行构建脚本。 +- 开发工程从实际安装位置解析公共 SDK,支持安装目录与可写工作区分离;重开制作记录保留原生会话和已保存需求。安装核验明确区分“未检查启用”与“启用失败”,按实际 bundle 继续处理,并在当前已登录的宿主页面验收。 - 企业连接继续作为独立插件下载并显式安装,基础安装包不预装企业账号与协作插件。 ## 发行文件 @@ -30,4 +31,6 @@ 当前为 Alpha 预发布,升级前请备份本地配置和数据。SkillHub、dsh-market 为独立第三方来源,目录内容并非全部预装或经 WorkDSH 审核,安装前请核对许可、依赖与版本兼容性。 +已有工作区若安装了仅适配旧 DSH 的第三方插件,官方插件管理器会阻止升级并恢复原依赖。例如 `sigmapad-plugin@0.1.0-alpha.180` 要求 DSH `0.2.0-rc.2`,与本次底座不兼容;需由插件方提供适配版本,再通过官方插件管理器升级。WorkDSH 不会自动删除或禁用这类插件,也不会绕过版本检查。干净新安装不包含此第三方插件。 + 开源地址:[github.com/techflag/workdsh](https://github.com/techflag/workdsh)