dsh-m 0.8.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md CHANGED
@@ -8,11 +8,11 @@
8
8
 
9
9
  English · [中文](./README.md)
10
10
 
11
- A DeepSeek Harness (DSH) plugin marketplace with a customizable registry: **browse, install, uninstall, upgrade** — all local. Ships as a DSH web plugin: the "Plugin Marketplace" sidebar entry opens a three-view panel, backed by eight `dshm_*` agent tools and the `dshm` CLI.
11
+ A DeepSeek Harness (DSH) plugin marketplace with a customizable registry: **browse, install, uninstall, upgrade** — all local. Ships as a DSH web plugin: the "Plugin Marketplace" sidebar entry opens a three-view panel, backed by eight `dshm_*` agent tools and the `dshm` CLI. The same package installs into **both the Web (`web`) and official Desktop (`desktop`) profiles** (0.9.0+; capability table in the changelog).
12
12
 
13
13
  <div align="center">
14
- <img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/marketplace.webp" alt="Plugin Marketplace panel — Market view: curated card flow with search and category filters" width="100%">
15
- <p><sub>The "Plugin Marketplace" panel · Market view: curated cards · search · category filters · one-click install</sub></p>
14
+ <img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/marketplace.webp" alt="Plugin Marketplace panel — Market view: Community / Curated / Favorites zones, category chips, and plugin cards (verified / installed badges, npm & GitHub detail links)" width="100%">
15
+ <p><sub>The "Plugin Marketplace" panel · Market view (Community 4,200+ · Curated 23 · Favorites): click a card to install</sub></p>
16
16
  </div>
17
17
 
18
18
  ## Quick start
@@ -38,10 +38,24 @@ The default registry includes DSH Skins, ModSearch, the Lark / QQ / Weixin / WeC
38
38
  |---|---|
39
39
  | **Market** | **Zoned** (0.7.0): **Community** (default landing, the 4,000+ full catalog) and **Curated** (hand-picked primary registry, single page) as two independent zones — each keeps its own category/search/sort/pagination state across tab switches; plus a **Favorites** zone (browser-local bookmarks with delisted-entry detection and one-click cleanup). Community zone: two-row collapsible category chips with sticky collapse, sort switch (downloads/stars/added × asc/desc), windowed pagination (24/48/96 per page), card byline (owner/downloads/stars); clicking a card opens a **detail modal** (superset of the card: download window, screenshot lightbox, capabilities collapsed by default, install command); npm sources pin the latest exact version, GitHub sources pin the release/tag commit |
40
40
  | **Operations** | Install/upgrade/uninstall/toggle all flow through a global operation log (0.7.0): state lives off cards, surviving paging/searching/tab switches; persisted to localStorage and resumed after host reloads (each restored op re-validated "still applies" before executing); benignly-invalidated ops show as a neutral "skipped"; the Installed page gains "Update all (N)" batch queueing |
41
- | **Installed** | What your web profile actually has, annotated "via market / non-market"; update badges, upgrade, two-step confirm uninstall; 📖 README preview (64KB cap); **live phase badge** (●active / ●failed / ○pending) and a **one-click toggle** (0.4.0: delegates to the official pluginManager service for live application; falls back to file-level edits + restart notice when absent; dsh-m itself and official host lifelines are locked) |
41
+ | **Installed** | What the current profile (web / desktop, 0.9.0+) actually has, annotated "via market / non-market"; update badges, upgrade, two-step confirm uninstall; 📖 README preview (64KB cap); **live phase badge** (●active / ●failed / ○pending) and a **one-click toggle** (0.4.0: delegates to the official pluginManager service for live application; falls back to file-level edits + restart notice when absent; dsh-m itself and official host lifelines are locked) |
42
42
  | **Settings** | Registry address draft with "Force refresh / Validate & apply / Restore default / Download default registry.json"; configured vs active address and status at a glance (shown only when abnormal); community catalog toggle; dsh-m self-update |
43
43
 
44
- After any mutation, the already-open Market and Installed views refresh the profile state together, keeping badges and cards in sync without closing and reopening the marketplace; a "⚡ Restart" banner appears — under systemd, the DSH launcher's `appExit` hook hands the restart back to a unit configured with `Restart=on-failure` or `Restart=always`, avoiding a `systemctl` helper inside the unit cgroup that is about to stop; if `appExit` is unavailable, the fallback uses a manager-owned transient `systemd-run` service and only then a detached helper. The client confirms the replacement by boot id and dismisses the banner, leaving DSH Web's own background connection recovery in control; it does not force a full-page reload during the auth/route handoff. The restart chain has been live-verified on the current DSH Web `0.2.0-rc.2` (2026-10-01): dsh-m probes the unit's `Restart=` policy, hands the stop back via `appExit`, the `status=75/TEMPFAIL` exit is caught by the Restart policy and auto-started, and after recovery the page reconnects in the background with the panel fully functional. Historical scope: the `0.1.5-rc.1` era covered live `/dshm` ping and authenticated `303 → 200` checks; `0.1.2-rc.1` is covered by contract checks plus runtime shape-detection fallback (see the verified array) — with the deployment moved on, no live E2E is chased on retired generations; the transient `systemd-run` fallback only applies to hosts without `appExit` (all supported generations provide it) and stays a documented fallback rather than a release gate; repeated install/uninstall is covered by the profile-transaction test surface (compensation, post-install guard, 763 cases) plus the 0.4.0→0.4.2 consecutive-release evidence. Installs stream live pnpm progress (resolve → download → link → build).
44
+ <div align="center">
45
+ <img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/settings.webp" alt="Settings view — community catalog status card (catalog version / route / entries), curated registry configuration card (address draft with force-refresh / validate & apply / restore default / download), and the About card" width="86%">
46
+ <p><sub>Settings view · community catalog status, curated registry configuration, and dsh-m self-update</sub></p>
47
+ </div>
48
+
49
+ The restart chain below applies to **Web only** (Desktop takes effect via the official app lifecycle: after installing, quit and reopen the Desktop app — see the 0.9.0 section). After any mutation, the already-open Market and Installed views refresh the profile state together, keeping badges and cards in sync without closing and reopening the marketplace; a "⚡ Restart" banner appears — under systemd, the DSH launcher's `appExit` hook hands the restart back to a unit configured with `Restart=on-failure` or `Restart=always`, avoiding a `systemctl` helper inside the unit cgroup that is about to stop; if `appExit` is unavailable, the fallback uses a manager-owned transient `systemd-run` service and only then a detached helper. The client confirms the replacement by boot id and dismisses the banner, leaving DSH Web's own background connection recovery in control; it does not force a full-page reload during the auth/route handoff. The restart chain has been live-verified on the current DSH Web `0.2.0-rc.2` (2026-10-01): dsh-m probes the unit's `Restart=` policy, hands the stop back via `appExit`, the `status=75/TEMPFAIL` exit is caught by the Restart policy and auto-started, and after recovery the page reconnects in the background with the panel fully functional. Historical scope: the `0.1.5-rc.1` era covered live `/dshm` ping and authenticated `303 → 200` checks; `0.1.2-rc.1` is covered by contract checks plus runtime shape-detection fallback (see the verified array) — with the deployment moved on, no live E2E is chased on retired generations; the transient `systemd-run` fallback only applies to hosts without `appExit` (all supported generations provide it) and stays a documented fallback rather than a release gate; repeated install/uninstall is covered by the profile-transaction test surface (compensation, post-install guard, 813 cases) plus the 0.4.0→0.4.2 consecutive-release evidence. Installs stream live pnpm progress (resolve → download → link → build).
50
+
51
+ ### New in 0.9.0 — Official Desktop (dual profile) support
52
+
53
+ - **One package, two profiles**: dsh-m now installs into the official Desktop's `desktop` profile (`~/.dsh/profiles/desktop`) alongside the Web `web` profile; the catalog, panel, and agent tools are shared, and everything manages the host's current profile (official `profileContext` as the single source of truth).
54
+ - **Entrust admission to the host**: every `/dshm` method (including ping and unknown methods) delegates to the official `connection.requestRejection()` before any body is read (trustedHosts / loopback / cross-site / `Origin: null` semantics come from the host); rejected requests consume no body and call no business logic; hosts without the capability fail closed. **Behavior change**: the old home-grown "missing Origin → 403" guard is gone — Origin-less requests from the Desktop bridge are admitted per official semantics, and unauthenticated health probes now follow host trust instead of a blanket 403.
55
+ - **Desktop first-release capability table**: read-only market + **installing new packages** (delegated to the official `pluginManager.installBundle`; integrity, locking, and application phases stay with the official manager) + **toggles** (delegated; structured refusal when the service is absent, never a file-level fallback); **upgrade / uninstall / self-update / one-click restart** return structured 409 refusals with official-entry guidance (no official upgrade API exists; restarts belong to the Electron lifecycle); build scripts retry with the exact official `pendingBuilds` list — never an allow-all.
56
+ - **Per-profile read model and cache**: market installed-badges, the installed list, and README previews only read the current profile; registry / community / accepted-source caches are segmented per profile (web keeps its legacy paths — zero migration, zero clearing); favorites and operation logs stay per browser origin and do not sync between Web and Desktop.
57
+ - **CLI always targets the web profile**: `--profile web` is explicit; `--profile desktop` is rejected with a pointer to the official Desktop plugin management page.
58
+ - **Honest limitations**: Desktop on-device (Win/macOS) E2E has not been run; the `registry.json` verified arrays gain **no** Desktop generation (to be recorded after real testing); dsh-m's file-level post-install guard is skipped on Desktop (app.asar probing blind spot) and replaced by official result checks plus a `listBundles` re-read.
45
59
 
46
60
  ### Fixed in 0.8.5
47
61
 
@@ -156,6 +170,8 @@ dshm restart --yes
156
170
 
157
171
  When the registry is unavailable, `registry` / `search` / `outdated` print the configured vs active address and exit 1; `list` still shows installed plugins. The CLI uses its own cache namespace and never touches the Web side's.
158
172
 
173
+ **Profile target (0.9.0)**: the CLI always acts on the web profile — `--profile web` makes it explicit; `--profile desktop` is rejected outright (Desktop plugin management belongs to the official Desktop plugin management page).
174
+
159
175
  ## Registry
160
176
 
161
177
  `registry.json` is hand-curated and fetched at runtime in order: **GitHub raw (`@main`) → GitHub mirror (jsDelivr CDN, backup line) → local 60-min TTL cache → bundled snapshot** — listing updates are decoupled from plugin releases. To add or amend a listing, edit `registry.json` and open a PR; CI validates the strict schema, npm/GitHub existence, duplicate ids and URL reachability.
@@ -202,6 +218,9 @@ Release: `npm version patch|minor|major && git push --tags` → OIDC trusted pub
202
218
  **1. Why don't GitHub-sourced update hints follow main?**
203
219
  Intermediate commits on main can be unstable. dsh-m tracks **releases / tags** only (`releases/latest` first, tags list as fallback) and pins the commit SHA the tag points to.
204
220
 
221
+ **1.5 What works on the official Desktop?**
222
+ Desktop (the `desktop` profile) first release supports: browsing the market, **installing new packages**, and plugin toggles; upgrade/uninstall/self-update/one-click restart return structured refusals with official-entry guidance (official plugin management page / Desktop app restart). Installed badges and the installed list reflect only what Desktop itself has installed; favorites and operation logs do not sync with the Web side.
223
+
205
224
  **2. Does uninstalling dsh-m delete my data?**
206
225
  No. Only the package reference in the profile is removed (live UI disabled first), and suspected leftover paths are reported to you.
207
226
 
package/README.md CHANGED
@@ -8,11 +8,11 @@
8
8
 
9
9
  [English](./README.en.md) · 中文
10
10
 
11
- 可自定义收录清单(Registry)的 DeepSeek Harness (DSH) 插件市场:**收录 · 安装 · 卸载 · 升级**,全部本机完成。以 DSH web 插件形态运行——侧栏「插件市场」打开三视图面板,同时提供 `dshm_*` agent 工具与 `dshm` CLI。
11
+ 可自定义收录清单(Registry)的 DeepSeek Harness (DSH) 插件市场:**收录 · 安装 · 卸载 · 升级**,全部本机完成。以 DSH web 插件形态运行——侧栏「插件市场」打开三视图面板,同时提供 `dshm_*` agent 工具与 `dshm` CLI;同一包装入 **Web(`web`)与官方 Desktop(`desktop`)两个 profile**(0.9.0 起,能力表见变更节)。
12
12
 
13
13
  <div align="center">
14
- <img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/marketplace.webp" alt="侧栏「插件市场」面板——市场视图:收录卡片流、关键词搜索与分类筛选" width="100%">
15
- <p><sub>侧栏「插件市场」· 市场视图:收录卡片流 · 搜索 · 分类筛选 · 展开即装</sub></p>
14
+ <img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/marketplace.webp" alt="侧栏「插件市场」面板——市场视图:社区 / 精选 / 收藏三分区、分类 chips 与插件卡片(已实测 / 已安装徽标、npm 与 GitHub 详情链接)" width="100%">
15
+ <p><sub>侧栏「插件市场」· 市场视图:社区 4,200+ · 精选 23 · 收藏,卡片即点即装</sub></p>
16
16
  </div>
17
17
 
18
18
  ## 30 秒上手
@@ -38,10 +38,24 @@
38
38
  |---|---|
39
39
  | **市场** | **分区制**(0.7.0):**社区**(默认落地页,4,000+ 条全量目录)与**精选**(手工策展主清单,单页直出)两个独立分区,各有自己的分类/搜索/排序/分页状态(切 tab 互不重置);另有**收藏**分区(浏览器本地书签,自动检测下架条目并一键清理)。社区区:分类 chips 两行折叠 + 吸顶收缩、排序(下载量/Star/收录日期 × 升降)、页码窗口化分页(24/48/96 每页)、卡片 byline(作者/下载量/Star);卡片点开**详情 Modal**(卡片信息超集:下载量窗口三要素、截图灯箱、能力披露默认收起、安装命令);npm 源锁定最新精确版本,GitHub 源锁定 release/tag 指向的 commit |
40
40
  | **操作记录** | 安装/升级/卸载/开关全部走全局操作记录(0.7.0):状态不挂卡片,翻页/搜索/切 tab 不丢;localStorage 持久化,宿主重载后自动恢复未完成操作(逐条校验「此刻仍成立才执行」);良性前提消失以中性「已跳过」呈现;已装页支持「全部更新 (N)」批量入队 |
41
- | **已装** | web profile 实装列表,标注「市场安装 / 非市场安装」;可升级徽标、升级、两段式确认卸载;📖 README 预览(64KB 截断);**运行相位徽标**(●active / ●failed / ○pending)与**一键开关**(0.4.0:委派官方 pluginManager 服务活体生效,服务缺席时文件级编辑 + 重启提示;dsh-m 自身与官方宿主命脉锁定不可开关) |
41
+ | **已装** | 当前 profile(web / desktop,0.9.0 起)实装列表,标注「市场安装 / 非市场安装」;可升级徽标、升级、两段式确认卸载;📖 README 预览(64KB 截断);**运行相位徽标**(●active / ●failed / ○pending)与**一键开关**(0.4.0:委派官方 pluginManager 服务活体生效,服务缺席时文件级编辑 + 重启提示;dsh-m 自身与官方宿主命脉锁定不可开关) |
42
42
  | **设置** | registry 地址草稿 +「强制刷新 / 校验并应用 / 恢复默认 / 下载默认 registry.json」;配置地址、生效来源与状态一目了然(仅异常时提示);社区目录开关;dsh-m 自更新 |
43
43
 
44
- 安装 / 卸载 / 升级完成后,当前已打开的市场页与已装页会一起重新读取 profile 状态并同步徽标/卡片,不需要关闭后重新打开插件市场;随后出现「⚡ 一键重启」横幅——受 systemd 管理时通过 DSH launcher 的 `appExit` 交给服务的 `Restart` 策略,避免在待停止 unit 的 cgroup 内启动 `systemctl` helper;无 `appExit` 的 systemd 兜底改用 manager-owned transient `systemd-run`,最后才退回 detached-helper。客户端按 boot id 确认新进程已恢复后关闭横幅,交由 DSH Web 自身的后台连接重试恢复页面,不强制整页刷新,避免认证/路由切换期间白屏。重启链路已在当前 DSH Web `0.2.0-rc.2` 实机核验(2026-10-01):dsh-m 探测 unit `Restart=` 策略后经 `appExit` 交还 systemd,`status=75/TEMPFAIL` 退出由 Restart 策略接住自动拉起,服务恢复后页面后台重连、面板全功能可用。历史口径:`0.1.5-rc.1` 时代已核验 `/dshm` ping 与带认证 `303 → 200`;`0.1.2-rc.1` 按契约核对 + 形态探测兜底收录(verified 数组),部署面演进后不再追旧代 live E2E;transient `systemd-run` 兜底仅适用于无 `appExit` 的宿主(受支持代际均提供 `appExit`),保持设计兜底而非发布门槛;连续安装/卸载由 profile 事务测试面(补偿事务、装后守卫等 763 用例)与 0.4.0→0.4.2 连续发布实证覆盖。安装过程实时显示 pnpm 进度(解析 → 下载 → 链接 → 构建)。
44
+ <div align="center">
45
+ <img src="https://raw.githubusercontent.com/iasiv5/dsh-m/main/docs/images/settings.webp" alt="设置视图——社区清单状态卡(目录版本 / 获取线路 / 收录条目)、精选清单配置卡(地址草稿与强制刷新 / 校验并应用 / 恢复默认 / 下载默认清单)与关于卡" width="86%">
46
+ <p><sub>设置视图 · 社区清单状态、精选清单配置与 dsh-m 自更新</sub></p>
47
+ </div>
48
+
49
+ 以下重启链路仅适用于 **Web**(Desktop 的生效走官方应用生命周期:安装完成后退出并重新打开 Desktop 应用即可,见 0.9.0 节)。安装 / 卸载 / 升级完成后,当前已打开的市场页与已装页会一起重新读取 profile 状态并同步徽标/卡片,不需要关闭后重新打开插件市场;随后出现「⚡ 一键重启」横幅——受 systemd 管理时通过 DSH launcher 的 `appExit` 交给服务的 `Restart` 策略,避免在待停止 unit 的 cgroup 内启动 `systemctl` helper;无 `appExit` 的 systemd 兜底改用 manager-owned transient `systemd-run`,最后才退回 detached-helper。客户端按 boot id 确认新进程已恢复后关闭横幅,交由 DSH Web 自身的后台连接重试恢复页面,不强制整页刷新,避免认证/路由切换期间白屏。重启链路已在当前 DSH Web `0.2.0-rc.2` 实机核验(2026-10-01):dsh-m 探测 unit `Restart=` 策略后经 `appExit` 交还 systemd,`status=75/TEMPFAIL` 退出由 Restart 策略接住自动拉起,服务恢复后页面后台重连、面板全功能可用。历史口径:`0.1.5-rc.1` 时代已核验 `/dshm` ping 与带认证 `303 → 200`;`0.1.2-rc.1` 按契约核对 + 形态探测兜底收录(verified 数组),部署面演进后不再追旧代 live E2E;transient `systemd-run` 兜底仅适用于无 `appExit` 的宿主(受支持代际均提供 `appExit`),保持设计兜底而非发布门槛;连续安装/卸载由 profile 事务测试面(补偿事务、装后守卫等 813 用例)与 0.4.0→0.4.2 连续发布实证覆盖。安装过程实时显示 pnpm 进度(解析 → 下载 → 链接 → 构建)。
50
+
51
+ ### 0.9.0 新增:官方 Desktop(双 profile)支持
52
+
53
+ - **同一包、两个 profile**:dsh-m 现在可装入官方 Desktop 的 `desktop` profile(`~/.dsh/profiles/desktop`),与 Web 的 `web` profile 并列;市场目录、面板与 agent 工具同一套,管理对象始终是宿主当前 profile(官方 `profileContext` 单一事实源)。
54
+ - **入口信任检查改委派官方**:`/dshm` 全部 method(含 ping 与未知 method)在读取请求体之前委派官方 `connection.requestRejection()` 判定(trustedHosts / loopback / 跨站 / `Origin: null` 语义随宿主),被拒请求零 body 消耗、零业务调用;宿主缺该能力时 fail-closed 全拒。**行为变化**:旧版自制守卫「缺 Origin 一律 403」不再存在——Desktop 桥合法剥除 Origin 的请求按官方语义放行,无凭据的健康检查探针从「一律 403」变为「按宿主信任判定」。
55
+ - **Desktop 首发能力表**:只读市场 + **安装新包**(委派官方 `pluginManager.installBundle`,完整性/锁/生效相位归官方)+ **开关**(委派官方管理器,服务缺席结构化拒绝、绝不文件级 fallback);**升级 / 卸载 / dsh-m 自更新 / 一键重启**在 Desktop 结构化拒绝(409,带官方入口指引——官方暂无 upgrade API,重启归 Electron 生命周期);构建脚本按官方 `pendingBuilds` 名单精确重试,绝不全量放行。
56
+ - **读模型与缓存按 profile 隔离**:市场安装标注、已装列表、README 预览只读当前 profile;registry / 社区清单 / accepted-source 缓存按 profile 分段(web 沿用旧路径,零迁移零清空);收藏与操作记录按浏览器 origin 各自独立,Web 与 Desktop 不自动同步。
57
+ - **CLI 恒作用于 web profile**:`--profile web` 显式声明;`--profile desktop` 明确拒绝并指引官方 Desktop 插件管理页。
58
+ - **如实声明**:Desktop 实机(Win/macOS)E2E 未跑,`registry.json` verified 数组**不新增** Desktop 代际(实测后按收录纪律补录);Desktop 下不做 dsh-m 文件级装后守卫(app.asar 打包布局探测盲区),以官方结果判定 + `listBundles` 复读替代。
45
59
 
46
60
  ### 0.8.5 修复
47
61
 
@@ -156,6 +170,8 @@ dshm restart --yes
156
170
 
157
171
  清单不可用时 `registry` / `search` / `outdated` 打印配置与实际生效地址并退出码 1;`list` 仍列出已装插件。CLI 固定独立缓存命名空间,不影响 Web 端。
158
172
 
173
+ **profile 目标(0.9.0)**:CLI 恒作用于 web profile——`--profile web` 为显式声明;`--profile desktop` 直接拒绝(Desktop profile 的插件管理走官方 Desktop 插件管理页)。
174
+
159
175
  ## 收录清单(registry)
160
176
 
161
177
  `registry.json` 手工 curated,运行时按 **GitHub 原始文件(raw @main)→ GitHub 镜像(jsDelivr CDN,备用线路)→ 本地 60 分钟 TTL 缓存 → 包内快照** 的顺序获取——收录更新与插件发版**解耦**,push 后最多等一个缓存周期(可在设置页强制刷新)。收录 / 修订直接改 `registry.json` 发 PR,CI 自动校验:严格 schema、npm 包与 GitHub 仓库存在性、重复 id、URL 可达性。
@@ -202,6 +218,9 @@ node scripts/validate-registry.mjs
202
218
  **1. 为什么 GitHub 来源的更新提示不走 main HEAD?**
203
219
  main 上的中间提交可能不稳定。dsh-m 只跟踪 **release / tag**(优先 `releases/latest`,无 release 回退 tags 列表),安装时锁定 tag 指向的 commit SHA。
204
220
 
221
+ **1.5 官方 Desktop 上能用哪些功能?**
222
+ Desktop(desktop profile)首发支持:浏览市场、**安装新包**、插件开关;升级/卸载/自更新/一键重启会返回结构化拒绝并指引官方入口(官方插件管理页 / Desktop 应用重启)。市场安装标注、已装列表只反映 Desktop 自己装了什么;与 Web 端的收藏、操作记录互不同步。
223
+
205
224
  **2. 卸载 dsh-m 会删我的数据吗?**
206
225
  不会。只移除 profile 中的包引用(卸载前先下线运行中的界面),并把疑似残留路径报告给你。
207
226
 
package/docs/DESIGN.md CHANGED
@@ -119,7 +119,7 @@ npm 安装 / GitHub 安装 / 升级 / 自升级 / 卸载是**同一个事务模
119
119
  - **卸载**(事务定序写死):validate(严格读取:NOT_INSTALLED / NOT_DSH_PLUGIN / PLUGIN_METADATA_UNREADABLE,全部零写入零快照)→ 快照 → live-disable(先让 client bundle 下线,避免 404;回滚时尽力反向)→ 摘除该包补丁条目(`pnpm-workspace.yaml` 顶层 `patchedDependencies` 与 `package.json#pnpm.patchedDependencies`;残留条目会令 pnpm 以 `ERR_PNPM_UNUSED_PATCH` 整单失败,只精确匹配 `pkg` / `pkg@ver`,补丁文件本体保留并计入残留报告)→ remove → verify gone(仍在则回滚)。**不清理插件产生的数据/配置**,但把检测到的疑似残留路径(如 `~/.dsh/<plugin>.json`)列出报告。
120
120
  - **升级**:**按需检查**(`dshm_outdated` / `dshm_list` 时实时比对本地版本 vs npm latest / GitHub release,半自动——展示升级计划,确认后执行,即重开一次安装事务)。**不做后台定时器**。
121
121
  - **自更新**:dsh-m 对自己同样做版本比对 + 提示升级(设置页呈现);执行即 `install-npm` 事务(integrity 缺失直接拒绝,不进事务)。
122
- - **开关(0.4.0,术语见 CONTEXT.md「开关/行覆盖/Bundle 选择/委派降级」)**:已装插件运行状态的可逆切换,**不经 Profile 变更事务**。粒度自动路由——包内补丁层单 insert 行且无配置补丁行 → 行覆盖(`cordis.patch.yml` 保注释 `disabled` 编辑,官方 `writePluginEnabled` 同款语义,启用写显式 `disabled: false`);多行/不可解析 → Bundle 选择(`dsh.profile.bundles` 数组增删)。写路径委派优先:运行时探测官方 `pluginManager` 服务(`ctx.get`,按存在性不判版本号)→ `setPluginEnabled`/`setBundleEnabled`(白拿保护判定、unaddressable 识别、hmr 活体重组;applied 以复读 `listPlugins()` 为准);服务缺席降级 loader `entry.update()` 直操作 + 锁内文件编辑(bundle 级恒 restart-required)。**必须持官方同款锁**(`withFileLock(<profile>/package.json)`)防与官方 UI 并发丢更新。架构决策见 [ADR-0001](./adr/0001-delegate-with-fallback-for-plugin-manager.md)。
122
+ - **开关(0.4.0,术语见 GLOSSARY.md「开关/行覆盖/Bundle 选择/委派降级」)**:已装插件运行状态的可逆切换,**不经 Profile 变更事务**。粒度自动路由——包内补丁层单 insert 行且无配置补丁行 → 行覆盖(`cordis.patch.yml` 保注释 `disabled` 编辑,官方 `writePluginEnabled` 同款语义,启用写显式 `disabled: false`);多行/不可解析 → Bundle 选择(`dsh.profile.bundles` 数组增删)。写路径委派优先:运行时探测官方 `pluginManager` 服务(`ctx.get`,按存在性不判版本号)→ `setPluginEnabled`/`setBundleEnabled`(白拿保护判定、unaddressable 识别、hmr 活体重组;applied 以复读 `listPlugins()` 为准);服务缺席降级 loader `entry.update()` 直操作 + 锁内文件编辑(bundle 级恒 restart-required)。**必须持官方同款锁**(`withFileLock(<profile>/package.json)`)防与官方 UI 并发丢更新。架构决策见 [ADR-0001](./adr/0001-delegate-with-fallback-for-plugin-manager.md)。
123
123
  - **运行相位(0.4.0)**:loader fiber 状态投影(active/failed/pending/loading/unloading;fiber 缺失即已停用),读路径始终自读 loader entries,读路径零服务依赖。
124
124
  - **保护名单(0.4.0)**:`dsh-m` 自身 + 逐字镜像官方 `protectedModules` 16 项全集(锚点 `dsh-plugin-manager@0.1.7-rc.2 lib/index.js L1077`;DSH 升级后 diff 官方名单)。作用于**开关与卸载**(保护门为开关路径最先判定,纯名单检查先于在装枚举);升级不受影响;委派路径信任官方 `readOnlyReason`。
125
125
  - **peer 兼容预检(0.4.0)**:install/upgrade 事务外 metadata 阶段,校验 `@deepseek-ai/dsh(-*)` peers 与运行时版本(`workspace:^/~/ *` 视为运行时版本;semver includePrerelease;官方 `evaluatePluginCompatibility` 同语义)。运行时版本解析失败或预检元数据读取失败 → 不拦(如实标注未检);不兼容 → GUI 弹确认 / agent 回结构化 issue / CLI `--force`;无豁免机制(确认即通道);GitHub 源明示 `compatSkipped`。
package/lib/cli.js CHANGED
@@ -138,6 +138,9 @@ const HELP = `dshm — DSH Marketplace(DSH 插件市场:精选策展 + 社
138
138
  dshm restart --yes
139
139
  注意:变更互斥仅在进程内生效——变更执行期间不要同时从 GUI / Agent 工具发起另一次变更。
140
140
 
141
+ profile 目标(0.9.0):CLI 恒作用于 web profile;--profile web 为显式声明,
142
+ --profile desktop 会被拒绝——Desktop profile 的插件管理走官方 Desktop 插件管理页。
143
+
141
144
  环境变量:DSHM_REGISTRY_URL(registry 源覆盖)、DSHM_TIMEOUT_MS、DSHM_CACHE_TTL_MIN、DSHM_CACHE_DIR、
142
145
  DSHM_COMMUNITY_CATALOG=0(退出社区清单)、DSHM_COMMUNITY_CATALOG_PIN(锁定社区目录版本)
143
146
  `;
@@ -166,6 +169,15 @@ async function runCliDispatch(argv, deps, io) {
166
169
  };
167
170
  const { cmd, flags } = parseArgs(argv);
168
171
  const cfg = cliConfig();
172
+ // 0.9.0 双 profile(ADR-0005):CLI 是 Web-only 入口(独立进程拿不到宿主 profileContext,
173
+ // 目标恒为 web profile);--profile desktop / 其他非 web 值显式拒绝并指引官方入口,
174
+ // 绝不隐式回落 web(报告 §5.3「不能从不存在的 Desktop 模式推断」)。
175
+ const profileFlag = typeof flags.profile === 'string' ? flags.profile.trim() : '';
176
+ if (profileFlag !== '' && profileFlag !== 'web') {
177
+ err(`错误:dshm CLI 仅作用于 web profile,不支持 --profile ${profileFlag}。`);
178
+ err('Desktop profile 的插件管理请使用官方 Desktop 的插件管理页(Settings → Plugins),或回到 DSH Web 端使用 dsh-m。');
179
+ return 1;
180
+ }
169
181
  switch (cmd) {
170
182
  case 'help':
171
183
  case '--help':
package/lib/client.js CHANGED
@@ -1219,6 +1219,8 @@ var ZH = {
1219
1219
  "restart.failed": "\u91CD\u542F\u5931\u8D25\uFF1A{err}",
1220
1220
  "restart.timeout": "\u91CD\u542F\u8D85\u65F6\uFF0C\u8BF7\u624B\u52A8\u68C0\u67E5 dsh web \u670D\u52A1\u72B6\u6001",
1221
1221
  "restart.hint.done": "\u5DF2\u8BF7\u6C42\u91CD\u542F DSH web\uFF08via {via}\uFF09\u3002\u670D\u52A1\u6062\u590D\u540E DSH Web \u4F1A\u5728\u540E\u53F0\u81EA\u52A8\u91CD\u8FDE\u3002",
1222
+ "profile.restartHint": "\u53D8\u66F4\u5B8C\u6210\u3002Desktop \u63D2\u4EF6\u7531\u5B98\u65B9\u5E94\u7528\u7BA1\u7406\uFF1A\u8BF7\u9000\u51FA\u5E76\u91CD\u65B0\u6253\u5F00 Desktop \u5E94\u7528\uFF08\u5173\u95ED\u7A97\u53E3\u53EF\u80FD\u53EA\u662F\u9690\u85CF\uFF09\u4EE5\u52A0\u8F7D\u65B0\u72B6\u6001\u3002",
1223
+ "profile.chipTitle": "\u5F53\u524D DSH profile\uFF1A{name}\uFF08Desktop \u9996\u53D1\u4EC5\u652F\u6301\u53EA\u8BFB\u5E02\u573A\u3001\u5B89\u88C5\u65B0\u5305\u4E0E\u5F00\u5173\uFF09",
1222
1224
  "phase.resolving": "\u89E3\u6790\u4F9D\u8D56",
1223
1225
  "phase.downloading": "\u4E0B\u8F7D",
1224
1226
  "phase.linking": "\u94FE\u63A5\u5B89\u88C5",
@@ -1472,6 +1474,8 @@ var EN = {
1472
1474
  "restart.failed": "Restart failed: {err}",
1473
1475
  "restart.timeout": "Restart timed out \u2014 check the dsh web service manually",
1474
1476
  "restart.hint.done": "Restart requested (via {via}). DSH Web will reconnect in the background after the service returns.",
1477
+ "profile.restartHint": "Changes applied. Desktop plugins are managed by the official app: quit and reopen the Desktop app (closing the window may only hide it) to load the new state.",
1478
+ "profile.chipTitle": "Current DSH profile: {name} (Desktop first release supports read-only market, installing new packages, and toggles)",
1475
1479
  "phase.resolving": "Resolving",
1476
1480
  "phase.downloading": "Downloading",
1477
1481
  "phase.linking": "Linking",
@@ -1913,7 +1917,7 @@ function TwoStepButton({ label, confirmLabel, className, onConfirm, disabled })
1913
1917
  arm ? confirmLabel : label
1914
1918
  );
1915
1919
  }
1916
- function RestartBanner({ note, onDone }) {
1920
+ function RestartBanner({ note, onDone, desktop }) {
1917
1921
  const [phase, setPhase] = useState("idle");
1918
1922
  const [err, setErr] = useState(null);
1919
1923
  const restart = useCallback(async () => {
@@ -1956,7 +1960,7 @@ function RestartBanner({ note, onDone }) {
1956
1960
  { className: "dshm-banner-text" },
1957
1961
  phase === "restarting" ? lookup("restart.doing") : phase === "waiting" ? lookup("restart.waiting") : err ? lookup("restart.failed", { err }) : note || lookup("banner.done")
1958
1962
  ),
1959
- phase === "idle" && !err ? h("button", { className: "dshm-btn primary sm", onClick: restart }, lookup("restart.now")) : null,
1963
+ phase === "idle" && !err && !desktop ? h("button", { className: "dshm-btn primary sm", onClick: restart }, lookup("restart.now")) : null,
1960
1964
  phase === "restarting" || phase === "waiting" ? Spin() : null,
1961
1965
  phase === "idle" && err ? h("button", { className: "dshm-btn sm", onClick: () => onDone(false) }, lookup("common.ok")) : null,
1962
1966
  phase === "idle" && !err ? h("button", { className: "dshm-btn sm", onClick: () => onDone(false) }, lookup("common.later")) : null
@@ -3332,10 +3336,13 @@ function MarketPanel({ onClose }) {
3332
3336
  removeIds: useCallback((ids) => setFavList(favStore.removeIds(ids)), [favStore])
3333
3337
  };
3334
3338
  const [pluginVersion, setPluginVersion] = useState(null);
3339
+ const [profile, setProfile] = useState(null);
3335
3340
  useEffect(() => {
3336
3341
  let live = true;
3337
3342
  api("ping").then((r) => {
3338
- if (live) setPluginVersion(typeof r?.version === "string" && r.version ? r.version : null);
3343
+ if (!live) return;
3344
+ setPluginVersion(typeof r?.version === "string" && r.version ? r.version : null);
3345
+ setProfile(r?.profile && typeof r.profile === "object" ? r.profile : null);
3339
3346
  }).catch(() => {
3340
3347
  });
3341
3348
  return () => {
@@ -3421,8 +3428,12 @@ function MarketPanel({ onClose }) {
3421
3428
  }, [toast]);
3422
3429
  const notify = useCallback(({ kind, text, needsRestart }) => {
3423
3430
  setToast({ kind, text });
3424
- if (kind === "ok" && needsRestart) setBanner({ text: lookup("banner.done") });
3425
- }, []);
3431
+ if (kind === "ok" && needsRestart) {
3432
+ setBanner(
3433
+ profile && profile.kind === "desktop" ? { desktop: true, text: lookup("profile.restartHint") } : { text: lookup("banner.done") }
3434
+ );
3435
+ }
3436
+ }, [profile]);
3426
3437
  return h(
3427
3438
  "div",
3428
3439
  { className: full ? "dshm-overlay full" : "dshm-overlay", onClick: onClose },
@@ -3456,6 +3467,7 @@ function MarketPanel({ onClose }) {
3456
3467
  ),
3457
3468
  h("span", { className: "dshm-spacer" }),
3458
3469
  pluginVersion ? h(DshmVersionChip, { version: pluginVersion }) : null,
3470
+ profile && profile.kind !== "web" ? h("span", { className: "dshm-dshchip", title: lookup("profile.chipTitle", { name: profile.name }) }, profile.name) : null,
3459
3471
  // 窗口控制组(0.7.7):最大化/还原 + 关闭,连体设计(系统化窗口按钮)
3460
3472
  h(
3461
3473
  "div",
@@ -3498,7 +3510,7 @@ function MarketPanel({ onClose }) {
3498
3510
  { className: toast.kind === "err" ? "dshm-banner err" : "dshm-banner" },
3499
3511
  h("span", { className: "dshm-banner-text" }, toast.text)
3500
3512
  ) : null,
3501
- banner ? h(RestartBanner, { note: banner.text, onDone: () => setBanner(null) }) : null
3513
+ banner ? h(RestartBanner, { note: banner.text, onDone: () => setBanner(null), desktop: banner.desktop === true }) : null
3502
3514
  )
3503
3515
  );
3504
3516
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * ActiveProfile(0.9.0 双 profile,ADR-0005):宿主当前 profile 的单一事实源。
3
+ *
4
+ * 事实源是官方 `ctx.get('profileContext')`(@deepseek-ai/dsh-app-boot,仅 dsh 启动的
5
+ * profile 存在);缺席(旧宿主 / 测试 / CLI)时降级 fallback = web 推导目录,且 source
6
+ * 如实标注 'fallback'。kind 决定能力表:desktop 只放行 install/set-enabled;
7
+ * 未知名一律拒写(报告 §4「对未知 profile 显式拒写」)。不得从页面 Origin、DSH_HOME、
8
+ * process.cwd() 或文件存在性猜 profile(报告 §4 同业旁证 #655/#744)。
9
+ */
10
+ import { webProfileDir, WEB_PROFILE } from './env.js';
11
+ /** Desktop 首发能力表(ADR-0005):只读市场 + 安装新包 + 开关;其余结构化拒绝并指引官方入口。 */
12
+ const DESKTOP_ALLOWED = new Set(['install', 'set-enabled']);
13
+ /** 结构化拒绝的官方入口指引(zh;GUI/工具/HTTP 三入口共用同一文案源)。 */
14
+ export const WRITE_GUIDANCE = {
15
+ install: '',
16
+ 'set-enabled': '',
17
+ upgrade: 'Desktop 暂不支持在市场内升级插件:请在官方 Desktop 的插件管理(Settings → Plugins)检查更新,或回到 DSH Web 使用 dsh-m 升级。',
18
+ uninstall: 'Desktop 暂不支持在市场内卸载插件:请使用官方 Desktop 的插件管理(Settings → Plugins)卸载。',
19
+ 'self-upgrade': 'Desktop 暂不支持 dsh-m 自更新:请在 DSH Web 端执行,或关注官方 Desktop 的插件更新入口。',
20
+ restart: 'Desktop 不由 dsh-m 重启:请退出并重新打开 Desktop 应用(关闭窗口可能只是隐藏),让新安装的插件生效。',
21
+ };
22
+ export class ProfileUnsupportedError extends Error {
23
+ code;
24
+ action;
25
+ profile;
26
+ guidance;
27
+ constructor(action, profile) {
28
+ const guidance = WRITE_GUIDANCE[action];
29
+ super(`当前 profile(${profile.name})不支持该操作(${action})。${guidance}`);
30
+ this.name = 'ProfileUnsupportedError';
31
+ this.code = 'unsupported-on-profile';
32
+ this.action = action;
33
+ this.profile = profile.name;
34
+ this.guidance = guidance;
35
+ }
36
+ }
37
+ function kindOf(name) {
38
+ if (name === 'desktop')
39
+ return 'desktop';
40
+ if (name === WEB_PROFILE)
41
+ return 'web';
42
+ return 'unknown';
43
+ }
44
+ /**
45
+ * 守卫式解析:宿主注入 profileContext → 事实取宿主;缺席/抛错/畸形 → fallback web。
46
+ * 在 host 生命周期内只应调用一次(host.ts apply 期),结果作为不可变对象传递。
47
+ */
48
+ export function resolveActiveProfile(ctx) {
49
+ let pc;
50
+ try {
51
+ const getter = ctx?.get;
52
+ if (typeof getter === 'function') {
53
+ pc = getter.call(ctx, 'profileContext');
54
+ }
55
+ }
56
+ catch {
57
+ pc = undefined;
58
+ }
59
+ const name = typeof pc?.name === 'string' && pc.name.trim() !== '' ? pc.name.trim() : undefined;
60
+ const dir = typeof pc?.dir === 'string' && pc.dir.trim() !== '' ? pc.dir.trim() : undefined;
61
+ if (pc === undefined || pc === null || name === undefined || dir === undefined) {
62
+ return { name: WEB_PROFILE, kind: 'web', dir: webProfileDir(), source: 'fallback' };
63
+ }
64
+ return { name, kind: kindOf(name), dir, source: 'host' };
65
+ }
66
+ /** 能力表守卫:违反即抛结构化 ProfileUnsupportedError(HTTP 409 / 工具 error result 共用)。 */
67
+ export function assertWriteAllowed(profile, action) {
68
+ if (profile.kind === 'web')
69
+ return;
70
+ if (profile.kind === 'desktop' && DESKTOP_ALLOWED.has(action))
71
+ return;
72
+ throw new ProfileUnsupportedError(action, profile);
73
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { mkdir, readFile, rm, rename, writeFile } from 'node:fs/promises';
10
10
  import { isAbsolute, join, relative } from 'node:path';
11
- import { cacheDir } from './env.js';
11
+ import { cacheRoot } from './env.js';
12
12
  import { decodeUtf8Fatal, fetchBytesLimited, describeFetchFailure } from './httpx.js';
13
13
  import { isExactVersion, npmLatest } from './versions.js';
14
14
  import { communityOutcome } from './market.js';
@@ -106,8 +106,9 @@ const ROUTE_LABELS = ['jsdelivr', 'npmmirror', 'unpkg'];
106
106
  const META_LATEST = 'meta.json';
107
107
  const metaPinName = (v) => `meta-pin-${v}.json`;
108
108
  const bodyName = (v) => `catalog-${v}.json`;
109
- function awesomeDir(namespace) {
110
- return join(cacheDir(), namespace, 'awesome');
109
+ /** 社区缓存目录(0.9.0 双 profile:web=旧路径,非 web 加 `<root>/<profile>` 段)。 */
110
+ function awesomeDir(namespace, profile = 'web') {
111
+ return join(cacheRoot(profile), namespace, 'awesome');
111
112
  }
112
113
  /** 路径 containment:目标必须仍在 dir 内(防御拼接穿越)。 */
113
114
  function contained(dir, p) {
@@ -198,7 +199,7 @@ function trimPin(cfg) {
198
199
  }
199
200
  async function runChain(cfg, opts, ctrl) {
200
201
  const namespace = opts.namespace ?? 'host';
201
- const dir = awesomeDir(namespace);
202
+ const dir = awesomeDir(namespace, opts.profile);
202
203
  const pin = trimPin(cfg);
203
204
  const budgetMs = opts.flightBudgetMs ?? COMMUNITY_CHAIN_BUDGET_MS;
204
205
  const startedAt = Date.now();
@@ -341,7 +342,7 @@ function flightKey(opts, pin) {
341
342
  const routesSig = opts.routes
342
343
  ? `${opts.routes.registryBase ?? ''}|${(opts.routes.fileBases ?? []).join(',')}`
343
344
  : 'default';
344
- return [ns, pin ?? '', opts.force ? 'force' : 'normal', routesSig].join('§');
345
+ return [ns, opts.profile ?? 'web', pin ?? '', opts.force ? 'force' : 'normal', routesSig].join('§');
345
346
  }
346
347
  function abortError() {
347
348
  const err = new Error('Aborted');
package/lib/core/env.js CHANGED
@@ -8,9 +8,21 @@ export function dshHome() {
8
8
  export function webProfileDir() {
9
9
  return join(dshHome(), 'profiles', WEB_PROFILE);
10
10
  }
11
- /** registry 缓存目录(可被 DSHM_CACHE_DIR 覆盖,便于测试) */
11
+ /**
12
+ * 缓存根(0.9.0 双 profile,ADR-0005):web 恒走旧路径(DSHM_CACHE_DIR 或
13
+ * `$DSH_HOME/dshm/cache`)——不迁移不清空;非 web profile 加同名段
14
+ * (`<root>/<profile>`),实现 profile 专属缓存互不污染。
15
+ * 测试经 DSHM_CACHE_DIR 指到临时目录(红线路径与既有用例一致)。
16
+ */
17
+ export function cacheRoot(profile = WEB_PROFILE) {
18
+ const base = process.env.DSHM_CACHE_DIR || join(dshHome(), 'dshm', 'cache');
19
+ if (profile === WEB_PROFILE)
20
+ return base;
21
+ return join(base, profile);
22
+ }
23
+ /** registry 缓存目录(可被 DSHM_CACHE_DIR 覆盖,便于测试)。web 语义的历史别名 = cacheRoot('web')。 */
12
24
  export function cacheDir() {
13
- return process.env.DSHM_CACHE_DIR || join(dshHome(), 'dshm', 'cache');
25
+ return cacheRoot(WEB_PROFILE);
14
26
  }
15
27
  /** 安装类操作的超时(毫秒) */
16
28
  export function installTimeoutMs() {
@@ -2,9 +2,10 @@
2
2
  * /dshm Host API dispatcher(DESIGN.md §4/§5):可注入的 method 分发 + 请求防护 +
3
3
  * HTTP 状态映射,供 host.ts 与契约测试共用。
4
4
  *
5
- * 解析顺序固定:只接受 POST → JSON Content-Type → 有上限读取并 drain body →
6
- * 顶层必须是非 null/非数组对象且有 method → `ping` 跳过 guard,否则
7
- * trustedRestartRequest host-equivalence guard → typed method/业务错误映射 → 其他 500。
5
+ * 解析顺序固定:宿主信任检查(0.9.0 起委派官方 `connection.requestRejection`,
6
+ * 全 method 含 ping 与未知 method,任何 body/side effect 之前,fail-closed)→
7
+ * 只接受 POST → JSON Content-Type → 有上限读取并 drain body →
8
+ * 顶层必须是非 null/非数组对象且有 method → typed method/业务错误映射 → 其他 500。
8
9
  */
9
10
  import { BOOT_ID, publicInstallStatus } from './dsh-cli.js';
10
11
  import { resolveDshVersion } from './dsh-version.js';
@@ -16,9 +17,11 @@ import { getCommunitySummary } from './community.js';
16
17
  import { RegistryConfigError } from './registry-controller.js';
17
18
  import { checkRegistryEntries } from './registry-check.js';
18
19
  import { CATEGORIES } from './registry.js';
19
- import { servingPort, scheduleRestart, trustedRestartRequest } from './restart.js';
20
+ import { servingPort, scheduleRestart } from './restart.js';
20
21
  import { togglePlugin, ToggleError } from './toggle.js';
21
22
  import { IncompatibleError } from './compat-check.js';
23
+ import { ProfileUnsupportedError, assertWriteAllowed } from './active-profile.js';
24
+ import { DesktopOpsError, desktopInstallFromRegistry, desktopToggle } from './profile-ops.js';
22
25
  export class BadJsonError extends Error {
23
26
  }
24
27
  export class BodyTooLargeError extends Error {
@@ -66,7 +69,20 @@ function readBody(req, maxBytes = BODY_MAX_BYTES) {
66
69
  });
67
70
  });
68
71
  }
69
- async function parseRequest(req) {
72
+ async function parseRequest(req, rejectRequest) {
73
+ // 宿主信任检查第一位(0.9.0 委派官方 requestRejection):先于 POST/Content-Type/body——
74
+ // 被拒请求零 body 消耗、零业务调用(报告 §3.2/§8)。返回 401/403 原样投影。
75
+ let rejection;
76
+ try {
77
+ rejection = rejectRequest(req);
78
+ }
79
+ catch {
80
+ // 宿主检查异常一律 fail-closed,异常细节不外泄(报告 §3.2)
81
+ rejection = 403;
82
+ }
83
+ if (rejection !== undefined) {
84
+ throw new ApiProtocolError(rejection, rejection === 401 ? '请求未通过宿主认证(未登录或凭据无效)' : '请求未通过宿主信任检查(不可信 Host/Origin 或跨站)');
85
+ }
70
86
  if ((req.method || 'GET').toUpperCase() !== 'POST') {
71
87
  throw new ApiProtocolError(405, '只接受 POST');
72
88
  }
@@ -91,9 +107,6 @@ async function parseRequest(req) {
91
107
  const method = typeof body.method === 'string' ? body.method.trim() : '';
92
108
  if (!method)
93
109
  throw new BadJsonError('缺少 method');
94
- if (method !== 'ping' && !trustedRestartRequest(req)) {
95
- throw new ApiProtocolError(403, '拒绝跨源请求');
96
- }
97
110
  return { method, body };
98
111
  }
99
112
  function strArg(body, key) {
@@ -139,6 +152,14 @@ function errorStatus(err) {
139
152
  }
140
153
  if (err instanceof ApiProtocolError)
141
154
  return { status: err.status, payload: { ok: false, error: err.message } };
155
+ if (err instanceof ProfileUnsupportedError) {
156
+ // 0.9.0:Desktop/未知 profile 的能力表拒绝——结构化 code + 官方入口指引
157
+ return { status: 409, payload: { ok: false, error: err.message, code: err.code, action: err.action, profile: err.profile, guidance: err.guidance } };
158
+ }
159
+ if (err instanceof DesktopOpsError) {
160
+ // 0.9.0:Desktop adapter 失败(no-manager/install-refused/enable-failed/verify-failed)
161
+ return { status: 409, payload: { ok: false, error: err.message, code: err.code } };
162
+ }
142
163
  if (err instanceof ToggleError) {
143
164
  const status = err.code === 'protected' ? 403 : err.code === 'not-installed' ? 404 : 409;
144
165
  return { status, payload: { ok: false, error: err.message, code: err.code } };
@@ -200,8 +221,14 @@ export function createApiDispatcher(ctx) {
200
221
  resolveDshVersion: ctx.deps?.resolveDshVersion ?? resolveDshVersion,
201
222
  getService: ctx.deps?.getService,
202
223
  toggle: ctx.deps?.togglePlugin ?? togglePlugin,
224
+ desktopInstall: ctx.deps?.desktopInstall ?? desktopInstallFromRegistry,
225
+ desktopToggle: ctx.deps?.desktopToggle ?? desktopToggle,
203
226
  };
204
227
  const cfg = () => ctx.controller.config;
228
+ // 0.9.0:宿主信任检查委派(含 ping 与未知 method,fail-closed);
229
+ // profile 为 host 生命周期内不可变对象(host.ts apply 期解析一次)
230
+ const rejectRequest = ctx.deps?.rejectRequest ?? (() => 403);
231
+ const profile = ctx.profile;
205
232
  // DSH 版本一次性解析,dispatcher 创建即预热(首个 ping 不吃 spawn 回退的延迟)
206
233
  const dshVersionP = Promise.resolve()
207
234
  .then(() => d.resolveDshVersion())
@@ -210,7 +237,7 @@ export function createApiDispatcher(ctx) {
210
237
  const abort = new AbortController();
211
238
  res.once('close', () => abort.abort());
212
239
  try {
213
- const { method, body } = await parseRequest(req);
240
+ const { method, body } = await parseRequest(req, rejectRequest);
214
241
  const signal = abort.signal;
215
242
  let payload;
216
243
  switch (method) {
@@ -222,6 +249,8 @@ export function createApiDispatcher(ctx) {
222
249
  boot: BOOT_ID,
223
250
  // DSH 运行版本(头部 chip 数据源);解析失败 → undefined → JSON 序列化时字段缺席
224
251
  dshVersion: (await dshVersionP) ?? undefined,
252
+ // 0.9.0 双 profile:GUI/工具展示当前 profile 与能力边界
253
+ profile: { name: profile.name, kind: profile.kind, source: profile.source },
225
254
  };
226
255
  break;
227
256
  case 'self-check': {
@@ -238,6 +267,8 @@ export function createApiDispatcher(ctx) {
238
267
  break;
239
268
  }
240
269
  case 'self-upgrade': {
270
+ // 0.9.0:Desktop 结构化拒绝(能力表),Web 走既有路径
271
+ assertWriteAllowed(profile, 'self-upgrade');
241
272
  // M2 Task 3:self-upgrade 收编 market.selfUpgrade(统一 mutation session + 守卫,直调事务旁路封死)
242
273
  const result = await d.selfUpgrade(ctx.pkg.name, ctx.pkg.version, cfg(), { signal });
243
274
  payload = {
@@ -327,7 +358,8 @@ export function createApiDispatcher(ctx) {
327
358
  const target = strArg(body, 'pkg');
328
359
  if (!target)
329
360
  throw new ApiProtocolError(400, '缺少 pkg');
330
- const result = await readInstalledPluginReadme(target);
361
+ // 0.9.0 双 profile:README 只读当前 profile 的 node_modules
362
+ const result = await readInstalledPluginReadme(target, profile.dir);
331
363
  payload = { ...result };
332
364
  break;
333
365
  }
@@ -338,6 +370,18 @@ export function createApiDispatcher(ctx) {
338
370
  const id = strArg(body, 'id');
339
371
  if (!id)
340
372
  throw new ApiProtocolError(400, '缺少 id');
373
+ // 0.9.0:desktop → 官方 pluginManager 委派(新包);web → 既有事务
374
+ if (profile.kind !== 'web') {
375
+ assertWriteAllowed(profile, 'install');
376
+ payload = { ...(await d.desktopInstall(id, cfg(), {
377
+ version: typeof body.version === 'string' ? body.version : undefined,
378
+ forceIncompatible: boolArg(body.forceIncompatible),
379
+ namespace: 'host',
380
+ profile: profile.name,
381
+ signal,
382
+ })) };
383
+ break;
384
+ }
341
385
  const version = typeof body.version === 'string' ? body.version : undefined;
342
386
  const result = await d.installFromRegistry(id, cfg(), {
343
387
  version,
@@ -352,6 +396,8 @@ export function createApiDispatcher(ctx) {
352
396
  const target = strArg(body, 'pkg');
353
397
  if (!target)
354
398
  throw new ApiProtocolError(400, '缺少 pkg');
399
+ // 0.9.0:Desktop 结构化拒绝(官方入口指引)
400
+ assertWriteAllowed(profile, 'uninstall');
355
401
  const result = await d.uninstallPlugin(target, cfg(), { namespace: 'host', signal });
356
402
  payload = { ...result };
357
403
  break;
@@ -362,6 +408,12 @@ export function createApiDispatcher(ctx) {
362
408
  throw new ApiProtocolError(400, '缺少 pkg');
363
409
  if (typeof body.enabled !== 'boolean')
364
410
  throw new ApiProtocolError(400, '缺少 enabled(必须为 boolean)');
411
+ // 0.9.0:desktop → 官方管理器委派(服务缺席结构化拒绝,禁 fallback 文件写)
412
+ if (profile.kind !== 'web') {
413
+ assertWriteAllowed(profile, 'set-enabled');
414
+ payload = { ...(await d.desktopToggle(target, body.enabled, { getService: d.getService, profileDir: profile.dir })) };
415
+ break;
416
+ }
365
417
  const result = await d.toggle(target, body.enabled, { getService: d.getService });
366
418
  payload = { ...result };
367
419
  break;
@@ -370,6 +422,8 @@ export function createApiDispatcher(ctx) {
370
422
  const target = strArg(body, 'pkg');
371
423
  if (!target)
372
424
  throw new ApiProtocolError(400, '缺少 pkg');
425
+ // 0.9.0:Desktop 结构化拒绝(无官方 upgrade API,指引 Settings → Plugins)
426
+ assertWriteAllowed(profile, 'upgrade');
373
427
  const result = await d.upgradePlugin(target, cfg(), {
374
428
  forceIncompatible: boolArg(body.forceIncompatible),
375
429
  namespace: 'host',
@@ -379,6 +433,8 @@ export function createApiDispatcher(ctx) {
379
433
  break;
380
434
  }
381
435
  case 'restart': {
436
+ // 0.9.0:Desktop 不触发旧 helper(Electron 生命周期归官方;杀子进程=红线)
437
+ assertWriteAllowed(profile, 'restart');
382
438
  const result = d.scheduleRestart(servingPort(req));
383
439
  payload = { ...result };
384
440
  break;