@gehennawu/dsh-service 2.0.5 → 2.0.7

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
@@ -9,9 +9,9 @@
9
9
  <em>DeepSeek Harness (DSH) Web 服务控制与运维插件。</em>
10
10
  </p>
11
11
 
12
- [![Version](https://img.shields.io/badge/version-2.0.5-3b82f6.svg?style=flat-square)](package.json)
12
+ [![Version](https://img.shields.io/badge/version-2.0.7-3b82f6.svg?style=flat-square)](package.json)
13
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-10b981.svg?style=flat-square)](LICENSE)
14
- [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20compatible%20with%200.2.1--alpha.1-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
14
+ [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20compatible%20with%200.2.1--alpha.2-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
15
15
  [![Cordis](https://img.shields.io/badge/Cordis-v4.x-f59e0b.svg?style=flat-square)](https://cordis.moe/)
16
16
  [![Platform](https://img.shields.io/badge/platform-DSH%20Web-ec4899.svg?style=flat-square)](https://github.com/gehennawu/dsh-service)
17
17
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/gehennawu/dsh-service/issues)
@@ -137,9 +137,11 @@ Under **Plugins → Plugin configuration**, twelve host-level switches: **Health
137
137
  | CLIProxyAPI deployment | Official remaining quota of each OAuth upstream account |
138
138
 
139
139
  - Credentials go into the DSH credential store (`$DSH_HOME/.credentials.yaml`, hot-effective): an API key, the CPA management key, the Xiaomi console cookie, or the StepFun Step Plan console token (Oasis-Token; the `Oasis-Webid` is derived from the token automatically — no manual entry); the Command Code quota plane reuses the inference key, so no extra credential is needed
140
- - Anti-rate-limit pacing: 60 s result cache, exponential backoff (30 s doubling, capped at 15 min); querying is fully manual — a snapshot is fetched when you open the quota page, expand the ring, or hit refresh, and the client never arms a periodic poll (upstream throttling still lives entirely in the host)
140
+ - **Credentials can be changed at any time**: every adapted card keeps a credential entry in its **Config** area — unconfigured rows show the per-channel wording, configured rows show "Edit credential (configured)", where the stored value can be overwritten (key rotation, another account, a typo) or cleared; the value is only ever sent with the save request and is never echoed back
141
+ - Anti-rate-limit pacing: 60 s result cache, exponential backoff for transient network errors (30 s doubling, capped at 15 min); **deterministic failures switch to manual** — credential errors, 4xx, expired plans, management API disabled, unexpected response shapes and anything else only a config change can fix stop the automatic retries (the error line then reads "Auto-retry paused — retry manually"), and only a successful manual reconnect via the refresh button (5 s cooldown) returns the channel to automatic mode; querying is fully manual — a snapshot is fetched when you open the quota page, expand the ring, or hit refresh, and the client never arms a periodic poll (upstream throttling still lives entirely in the host)
142
+ - **CLIProxyAPI management-plane bans get their own notice**: five consecutive auth failures from one IP ban that IP for 30 minutes on the management plane; while banned the card reads "Temporarily banned by CLIProxyAPI" plus the upstream message (including the remaining time). That state offers no credential form (the key is fine) — wait for the ban to expire, or change the host's egress IP, then hit "Retry connection". The ban covers the management plane only; model inference through that CPA is unaffected
141
143
  - CLIProxyAPI: when an account's live query fails, its last cached snapshot windows are shown with a "cached" badge; snapshot windows whose reset time has already passed (the window they described has ended) are dropped, avoiding the illusion of quota stuck on yesterday
142
- - Failures state their real reason: cards and the ring show "error copy (HTTP status · failing endpoint · failing account · upstream message) · next automatic retry" — a wrong key, an unpaid balance, rate limiting, and a moved endpoint each read differently instead of one generic notice; an upstream 401/403 is classified as "credential rejected by upstream" and the card keeps its credential form available, and business error codes inside an HTTP 200 envelope are classified the same way (auth failure → credential rejected, expired plan → no active subscription, vendor-side failure → upstream service error) instead of being reported as "unexpected response format"
144
+ - Failures state their real reason: cards and the ring show "error copy (HTTP status · failing endpoint · failing account · upstream message) · next automatic retry" — a wrong key, an unpaid balance, rate limiting, and a moved endpoint each read differently instead of one generic notice; an upstream 401/403 is classified as "credential rejected by upstream" and the card keeps its credential form available, and business error codes inside an HTTP 200 envelope are classified the same way (auth failure → credential rejected, expired plan → no active subscription, vendor-side failure → upstream service error) instead of being reported as "unexpected response format"; rows switched to manual mode end with "Auto-retry paused — retry manually" instead of a retry time
143
145
  - API keys are resolved only inside the host process; the browser receives normalized window data only; unadapted providers are never requested
144
146
 
145
147
  ### Backup management
@@ -153,7 +155,9 @@ Under **Plugins → Plugin configuration**, twelve host-level switches: **Health
153
155
  - Export download / import upload / delete (two-step confirmation); unlimited, never auto-pruned; imported archives must pass the same integrity inspection used by restore
154
156
  - **Integrity inspection** validates gzip/tar structure, paths, and entry types. Only `sessions`, the three allowlisted config files, `profiles/<name>/package.json`, and `meta/backup.json` are accepted; traversal, links, special files, unknown content, corrupt archives, and invalid profile manifests are rejected
155
157
  - **Restore preflight** produces a single-use five-minute plan showing the full sessions replacement, config replacements/removals, and profile-manifest updates. Final commit rechecks the archive SHA-256 and current-target fingerprint; any drift rejects the restore
156
- - Restore uses a transaction journal and rollback directory: sessions are replaced in full, config is made exact to the snapshot, and profiles update package.json only while keeping node_modules, credentials, and attachments. Managed runtimes restart automatically; manual launches receive hand-restart instructions
158
+ - **Cross-device / cross-platform restore**: every path inside an archive is relative and restore lands under the local `$DSH_HOME`, so a different DSH_HOME, username, or mount point makes no difference to import; `meta/backup.json` also records the source platform and architecture, and the preflight reports when the source system differs from the current one. The preflight additionally lists read-only notices (they never block a restore): paths past the classic Windows limit, paths differing only by case collapsing on this volume, and a sessions directory that is currently unwritable (in use or lacking permissions)
159
+ - **Target profile selection**: you choose which local profile the archive's `profiles/<name>` content restores into — candidates come from the profiles that actually exist here (the active one is marked), or you can keep the source name. A cross-name mapping does **not** overwrite the local profile's plugin manifest (`package.json`) by default; tick the box to overwrite it explicitly. The Profile configuration layer (`cordis.patch.yml`) is restored whenever the archive carries it
160
+ - Restore uses a transaction journal and rollback directory: sessions are replaced in full, config is made exact to the snapshot, and profiles update according to that mapping while keeping node_modules, credentials, and attachments. Managed runtimes restart automatically; manual launches receive hand-restart instructions. On Windows, a target left in use by another process fails with explicit guidance (quit the desktop app, pause sync clients, disable real-time antivirus scanning, then retry)
157
161
 
158
162
  ### Skills management
159
163
 
@@ -376,7 +380,9 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
376
380
 
377
381
  Requirements: Node.js `>=22`, and a DSH Web installation capable of loading both Host and Client plugin halves. Update checks require access to `registry.npmjs.org`; network failures do not affect other features.
378
382
 
379
- **DSH compatibility statement**: adapted to DSH `0.2.1-alpha.1` — session format V4 (V3 logs are migrated by the official layer into a `session.v4.jsonl.zstd` generation file on first read, with the old `session.v3.jsonl.zstd` retained per the format catalog's policy; the detail view automatically archives system events), the `SettingsForms` configuration surface (plugin config now lives in the Profile's `cordis.patch.yml` with hot updates over `loader/volatile-update`, while the legacy `settings.register` remains authoritative on 0.1.5/0.1.6 so the two never cross-contaminate on a shared host), and all existing V3 adaptation (the `system/message` history, the handle-based sessionPersistence, the official right sidebar, the object-shaped turn-process), plus dual-hash compatibility for mobile bottom-row triggers, subagent turn-tail list-slot adaptive compatibility, `plugins.bundle.config` slot injection, and session-detail open fallback through `uiWorkspace`. Older DSH releases (`>=0.1.1-rc.2`) remain supported. The declared range now extends through `0.2.1-alpha.1` (`engines.dsh` upper bound `<0.2.1-alpha.2`); source-level, npm-artifact-level (per-source-module sha256 via rolldown `//#region` slices) and real-host mounting reviews of 0.2.1-alpha.1 (266 commits) confirmed that 17 of the 20 existing seams are region-identical and the 3 changed ones do not touch this plugin's contracts, that the 9 slot keys, 25 theme tokens and the 35 official `data-*` hooks this plugin uses show zero removals, and that host RPC, `webServer`, the settings surface, persistence and session-format artifacts are byte-identical; the removed runtime invariant mechanism (each package's `./invariant` export 38->0, `dsh-invariants` retired) is unreferenced here, and the upstream fix for toggling a plugin removing other plugins' styles benefits this plugin. **The single display-level break is adapted in dual mode**: upstream split the composer stats row into `activity`/`usage` entries and dropped the row root class `.root`, so the mobile rules keep the legacy `[class*="bOPqQW_root"]` anchor for older hosts and add the official `[data-composer-stat]` hook plus the <=375px font step for the new host; the 0.2.0-rc.2 MenuGroup group-heading dual selector remains effective and the Client Inspect hang defect stays fixed as of 0.2.0-rc.2. The plugin-version gate introduced in rc.1 reads only `peerDependencies` (this plugin declares none, so the gate does not apply); real-host mounting on DSH `0.2.1-alpha.1` is operational. The settings surface and the persistence/layout seams all run in dual shapes detected from runtime capabilities, and adaptation items that target newer structures are naturally inert on older hosts (cosmetic only, no functional loss). Note: sessions written after upgrading cannot be read by older DSH releases — **backups do not restore across a version downgrade**. The plugin marketplace judges compatibility from the `engines.dsh` range in `package.json`, which is the single declaration of the supported range.
383
+ **DSH compatibility statement (alpha.1 baseline)**: adapted to DSH `0.2.1-alpha.1` — session format V4 (V3 logs are migrated by the official layer into a `session.v4.jsonl.zstd` generation file on first read, with the old `session.v3.jsonl.zstd` retained per the format catalog's policy; the detail view automatically archives system events), the `SettingsForms` configuration surface (plugin config now lives in the Profile's `cordis.patch.yml` with hot updates over `loader/volatile-update`, while the legacy `settings.register` remains authoritative on 0.1.5/0.1.6 so the two never cross-contaminate on a shared host), and all existing V3 adaptation (the `system/message` history, the handle-based sessionPersistence, the official right sidebar, the object-shaped turn-process), plus dual-hash compatibility for mobile bottom-row triggers, subagent turn-tail list-slot adaptive compatibility, `plugins.bundle.config` slot injection, and session-detail open fallback through `uiWorkspace`. Older DSH releases (`>=0.1.1-rc.2`) remain supported. The alpha.1 review established the previous declared range through `0.2.1-alpha.1` (then bounded by `<0.2.1-alpha.2`); source-level, npm-artifact-level (per-source-module sha256 via rolldown `//#region` slices) and real-host mounting reviews of 0.2.1-alpha.1 (266 commits) confirmed that 17 of the 20 existing seams are region-identical and the 3 changed ones do not touch this plugin's contracts, that the 9 slot keys, 25 theme tokens and the 35 official `data-*` hooks this plugin uses show zero removals, and that host RPC, `webServer`, the settings surface, persistence and session-format artifacts are byte-identical; the removed runtime invariant mechanism (each package's `./invariant` export 38->0, `dsh-invariants` retired) is unreferenced here, and the upstream fix for toggling a plugin removing other plugins' styles benefits this plugin. **The single display-level break is adapted in dual mode**: upstream split the composer stats row into `activity`/`usage` entries and dropped the row root class `.root`, so the mobile rules keep the legacy `[class*="bOPqQW_root"]` anchor for older hosts and add the official `[data-composer-stat]` hook plus the <=375px font step for the new host; the 0.2.0-rc.2 MenuGroup group-heading dual selector remains effective and the Client Inspect hang defect stays fixed as of 0.2.0-rc.2. The plugin-version gate introduced in rc.1 reads only `peerDependencies` (this plugin declares none, so the gate does not apply); real-host mounting on DSH `0.2.1-alpha.1` is operational. The settings surface and the persistence/layout seams all run in dual shapes detected from runtime capabilities, and adaptation items that target newer structures are naturally inert on older hosts (cosmetic only, no functional loss). Note: sessions written after upgrading cannot be read by older DSH releases — **backups do not restore across a version downgrade**. The plugin marketplace judges compatibility from the `engines.dsh` range in `package.json`, which is the single declaration of the supported range.
384
+
385
+ **DSH 0.2.1-alpha.2 compatibility update**: the supported range now includes `0.2.1-alpha.2` (`engines.dsh`: `>=0.1.1-rc.2 <0.2.1-alpha.3`). Subagent startup supports both the new `startActivation(spec)` and older `start` / `startContinuable` methods. Mobile task-chip CSS handles the removal of `.QsffPG_root` and the switch to a body-portaled task menu. On the current alpha.2 Web host, the plugin RPC, `available:true` subagent seam, real `subagent` and `subagent_fork` executions and their routed dispatch records were verified; legacy and new API tests pass. With an actual background task on alpha.2, the mobile chip geometry was checked at 320/360/390/560px, its in-flow fallback at 561px, and the portaled menu was opened twice at 390px without crossing the viewport. At 320px the task-count text is fully elided, but the icons and complete `aria-label` remain. **A real older-host UI comparison and other tab counts remain unverified.**
380
386
 
381
387
  **DSH Desktop (Electron) statement**: the desktop app reuses the same Web frontend and the same client plugin graph (`platform: web`), so the plugin loads as usual and almost everything works unchanged; only "restart" and "one-click upgrade" differ in meaning. The host is owned by the desktop app, so **restart never calls `process.exit(42)`** there (the shell treats any non-zero exit as a host crash, shows a native failure dialog and does not relaunch it; there is no official programmatic restart RPC) — the Restart button, the `/restart` command and the post-restore restart all guide the user to quit and reopen the desktop app instead; **one-click upgrade is greyed out on the desktop** and points at the app's own plugin manager (the bundled CLI cannot boot or mutate `profiles/desktop`, so the upgrade endpoint short-circuits with `desktop-managed-upgrade` and spawns nothing); the Health diagnostics "Runtime environment" row reads "Managed by DSH Desktop (Electron)". The signal is `process.versions.electron` inside the host process (measured on the real Windows desktop build: `44.0.0`, with `ELECTRON_RUN_AS_NODE=1` and non-TTY stdio); non-Electron desktop wrappers can declare `DSH_SERVICE_RUNTIME_ENV=desktop`. Task notifications **do work on the desktop app** (the permission is granted and a real system notification has been observed); the only boundary is that **clicking a notification cannot bring the window back** (closing the window hides it and `dshDesktop` exposes no window API — use the tray). macOS / Linux desktop builds are not verified.
382
388
 
@@ -414,7 +420,13 @@ Check two things first: (1) the **master notification switch** under "Configurat
414
420
  <details>
415
421
  <summary><strong>A quota card shows "credential missing"?</strong></summary>
416
422
 
417
- Use the inline form on the card: an API key for regular adaptations, the management key for CLIProxyAPI (not the proxy key), and the console cookie for Xiaomi Token Plan. The value goes into the DSH credential store and the provider refreshes automatically; if a process environment variable shadows the name, the host refuses the write — change the variable itself.
423
+ Use the inline form on the card: an API key for regular adaptations, the management key for CLIProxyAPI (not the proxy key), and the console cookie for Xiaomi Token Plan. The value goes into the DSH credential store and the provider refreshes automatically; if a process environment variable shadows the name, the host refuses the write — change the variable itself. To fix a typo or rotate a key, a configured card keeps an "Edit credential (configured)" entry in its **Config** area where the stored value can be overwritten or cleared.
424
+ </details>
425
+
426
+ <details>
427
+ <summary><strong>A CLIProxyAPI card shows "Temporarily banned by CLIProxyAPI"?</strong></summary>
428
+
429
+ Repeated wrong management keys tripped the CPA fail2ban: five failures from one IP ban that IP for 30 minutes, and while banned **even the correct key is rejected**. That state deliberately offers no credential form (the key itself is fine). Wait for the ban to expire (the upstream message carries the remaining time), or change the DSH host's egress IP, then hit "Retry connection". The ban applies to the `/v0/management` plane only — model inference through that CPA keeps working.
418
430
  </details>
419
431
 
420
432
  <details>
@@ -444,7 +456,7 @@ The token expired (the official `oasis-token is embezzled` error means the token
444
456
  <details>
445
457
  <summary><strong>What does restoring a backup do?</strong></summary>
446
458
 
447
- The host first runs an integrity inspection and presents a restore-preflight plan for final confirmation. Immediately before commit it rechecks the backup SHA-256, current-target fingerprint, and running work; any change aborts without a partial overwrite or restart. A successful commit replaces sessions in full, makes the allowlisted config files exact to the snapshot, and updates only each profile's package.json (node_modules, credentials, and attachments stay untouched). Docker/systemd/pm2-style managed runtimes restart automatically; likely terminal-launched instances show manual-restart instructions. Deleting a backup also requires two-step confirmation.
459
+ The host first runs an integrity inspection and presents a restore-preflight plan for final confirmation. Immediately before commit it rechecks the backup SHA-256, current-target fingerprint, and running work; any change aborts without a partial overwrite or restart. A successful commit replaces sessions in full, makes the allowlisted config files exact to the snapshot, and updates profiles exactly as the preflight mapping says (a cross-name mapping restores only the Profile configuration layer unless you tick the manifest box; node_modules, credentials, and attachments stay untouched). Docker/systemd/pm2-style managed runtimes restart automatically; likely terminal-launched instances show manual-restart instructions. Deleting a backup also requires two-step confirmation.
448
460
  </details>
449
461
 
450
462
  <details>
package/README.md CHANGED
@@ -9,9 +9,9 @@
9
9
  <em>A service-control &amp; operations plugin for DeepSeek Harness (DSH) Web.</em>
10
10
  </p>
11
11
 
12
- [![Version](https://img.shields.io/badge/version-2.0.5-3b82f6.svg?style=flat-square)](package.json)
12
+ [![Version](https://img.shields.io/badge/version-2.0.7-3b82f6.svg?style=flat-square)](package.json)
13
13
  [![License: MIT](https://img.shields.io/badge/License-MIT-10b981.svg?style=flat-square)](LICENSE)
14
- [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20%E5%B7%B2%E9%80%82%E9%85%8D%200.2.1--alpha.1-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
14
+ [![DSH Compatibility](https://img.shields.io/badge/DSH-%E2%89%A50.1.1--rc.2%20%C2%B7%20%E5%B7%B2%E9%80%82%E9%85%8D%200.2.1--alpha.2-6366f1.svg?style=flat-square)](https://github.com/deepseek-ai/deepseek-harness)
15
15
  [![Cordis](https://img.shields.io/badge/Cordis-v4.x-f59e0b.svg?style=flat-square)](https://cordis.moe/)
16
16
  [![Platform](https://img.shields.io/badge/platform-DSH%20Web-ec4899.svg?style=flat-square)](https://github.com/gehennawu/dsh-service)
17
17
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](https://github.com/gehennawu/dsh-service/issues)
@@ -139,9 +139,11 @@ DSH Web 服务控制与运维插件:安全重启、版本管理与一键升级
139
139
  | CLIProxyAPI 部署 | 各 OAuth 上游账号官方剩余额度 |
140
140
 
141
141
  - 凭据写入 DSH 凭据库(`$DSH_HOME/.credentials.yaml`,热生效):普通适配填 API key,CLIProxyAPI 填管理密钥,小米填控制台 Cookie,StepFun Step Plan 填控制台令牌(Oasis-Token,`Oasis-Webid` 由令牌自动派生无需手填);Command Code 额度面与推理面同一把 key,无需另配
142
- - 防风控:结果缓存 60 秒、失败指数退避(30 秒 ×2、封顶 15 分钟);查询为纯手动——打开额度页/展开圆环/点刷新才拉取,客户端不排任何周期轮询(上游节流仍全部在宿主侧)
142
+ - **凭据可随时修改**:每张已适配卡片的「配置」区常驻凭据入口——未配置时显示各渠道的填写文案,已配置后显示「修改凭据(已配置)」,可直接覆盖(密钥轮换、换账号、填错想改)或清除已存值;凭据值只随保存请求发出,绝不回显
143
+ - 防风控:结果缓存 60 秒、瞬时网络错误指数退避(30 秒 ×2、封顶 15 分钟);**确定性失败转手动**——凭据类、4xx、套餐失效、管理面未启用、响应格式异常等「换配置才可能恢复」的错误,宿主停止自动重试(错误行改报「自动重试已暂停,请手动重试」),只有点刷新手动重连(带 5 秒冷却)且成功后,才回到自动模式;查询为纯手动——打开额度页/展开圆环/点刷新才拉取,客户端不排任何周期轮询(上游节流仍全部在宿主侧)
144
+ - **CLIProxyAPI 管理面封禁有专属提示**:管理面对同一 IP 连续认证失败 5 次封 30 分钟,被封时卡片显示「已被 CLIProxyAPI 暂时封禁」+ 上游原话(含剩余时间);该状态不提供重填密钥的入口(密钥没错),等封禁解除或更换出口 IP 后点「重试连接」即可;封禁只作用于管理面,走该 CPA 的模型推理不受影响
143
145
  - CLIProxyAPI 某账号实时查询失败时,回退显示其上次缓存的快照窗口并标注「缓存」徽标;重置时间已过的快照窗口(快照描述的窗口已结束)直接丢弃,避免「额度停在昨天」的错觉
144
- - 失败原因如实呈现:卡片与圆环显示「错误文案(HTTP 状态 · 失败端点 · 失败账号 · 上游原话)· 下次自动重试时刻」——错 key、欠费、限流、路径变更各有各的上游原话与状态码,不再只有一个笼统提示;上游 401/403 判为「凭据被上游拒绝」,卡片同时保留凭据填写入口;HTTP 200 业务信封里的错误码同样定族——鉴权失败判凭据被拒(保留填写入口)、套餐到期判无生效订阅、上游自身故障判「上游服务故障」,不再一律报「响应格式异常」
146
+ - 失败原因如实呈现:卡片与圆环显示「错误文案(HTTP 状态 · 失败端点 · 失败账号 · 上游原话)· 下次自动重试时刻」——错 key、欠费、限流、路径变更各有各的上游原话与状态码,不再只有一个笼统提示;上游 401/403 判为「凭据被上游拒绝」,卡片同时保留凭据填写入口;HTTP 200 业务信封里的错误码同样定族——鉴权失败判凭据被拒(保留填写入口)、套餐到期判无生效订阅、上游自身故障判「上游服务故障」,不再一律报「响应格式异常」;转手动模式的错误行末尾改报「自动重试已暂停,请手动重试」
145
147
  - API key 只在宿主进程内解析,浏览器仅收到归一化窗口数据;未适配的供应商绝不发起请求
146
148
 
147
149
  ### 备份管理
@@ -155,7 +157,9 @@ DSH Web 服务控制与运维插件:安全重启、版本管理与一键升级
155
157
  - 导出下载 / 导入上传 / 删除(两段式确认);不限份数、不自动清理;导入归档必须先通过与恢复相同的完整性检查
156
158
  - **完整性检查**:恢复前校验 gzip/tar、路径与条目类型,只接受 `sessions`、三份允许配置、`profiles/<name>/package.json` 与 `meta/backup.json`;拒绝越界路径、链接、特殊文件、未知内容、损坏归档与非法 profile 清单
157
159
  - **恢复预检**:先生成 5 分钟有效的一次性计划,展示会话整体替换、配置覆盖/移除和 profile manifest 覆盖清单;最终确认前再次校验归档 SHA-256 与当前目标指纹,发生漂移则拒绝执行
158
- - 恢复提交使用事务日志和回滚目录:会话整体替换,配置按快照精确替换,profile 只更新 package.json 并保留 node_modules/凭据/附件;成功后托管环境自动重启,手动启动环境提示用户手动重启
160
+ - **跨设备 / 跨平台恢复**:归档内的路径全部是相对的,恢复按**本机** `$DSH_HOME` 落地,所以 DSH_HOME、用户名、挂载点不同都不影响导入;`meta/backup.json` 另记录备份时的来源平台与架构,预检会提示来源与当前系统不同。预检同时列出只读提示(不阻断恢复):路径超过 Windows 经典上限、仅大小写不同的路径在当前卷上会折叠、会话目录当前不可写(占用或权限不足)
161
+ - **目标 profile 选择**:归档里的 `profiles/<名>` 内容恢复进**本机哪个 profile** 由你选——候选来自本机实际存在的 profile(含当前活动 profile 标记),或保持与源同名;跨名映射默认**不覆盖**本机 profile 的插件清单 `package.json`,可显式勾选覆盖;Profile 配置层 `cordis.patch.yml` 归档有则照常恢复
162
+ - 恢复提交使用事务日志和回滚目录:会话整体替换,配置按快照精确替换,profile 按上述映射更新并保留 node_modules/凭据/附件;成功后托管环境自动重启,手动启动环境提示用户手动重启;Windows 上目标文件被占用导致失败时给出明确指引(退出桌面应用、暂停同步盘、关闭杀毒实时扫描后重试)
159
163
 
160
164
  ### 技能管理
161
165
 
@@ -378,7 +382,9 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
378
382
 
379
383
  运行要求:Node.js `>=22`,DSH Web 能加载 Host 与 Client 两半插件。更新检查需访问 `registry.npmjs.org`;网络失败不影响其他功能。
380
384
 
381
- **DSH 适配口径**:已适配 DSH `0.2.1-alpha.1`——会话格式 V4(V3 日志首读时由官方迁移为 `session.v4.jsonl.zstd` 代际文件,旧 `session.v3.jsonl.zstd` 按格式目录策略保留;详情视图自动归档系统事件)、`SettingsForms` 配置面(插件配置改由 Profile 的 `cordis.patch.yml` 承载,热更新走 `loader/volatile-update`;旧的 `settings.register` 在 0.1.5/0.1.6 上仍是权威来源,装在同一宿主上两者互不串台)、会话格式 V3 既有适配全部保留(`system/message` 入史、sessionPersistence handle 化、官方右栏、turn-process 对象化)、移动端底行触发钮双哈希兼容、子代理回合尾模型行 list 槽位自适应兼容、`plugins.bundle.config` 槽位注入、会话详情打开接入 `uiWorkspace` 降级链路。旧版 DSH(`>=0.1.1-rc.2`)保持兼容。支持区间扩展到 `0.2.1-alpha.1`(`engines.dsh` 上界 `<0.2.1-alpha.2`);0.2.1-alpha.1 源码级、npm 产物级(按 rolldown `//#region` 切片做逐源模块 sha256)与真机挂载审查(266 commits)确认:20 条既有接缝 17 条区域级逐字节相同、3 条均不触及本插件契约,9 个 slot key、25 个主题令牌、插件使用的 35 项官方 `data-*` 零丢失,宿主 RPC / `webServer` / 设置面 / persistence / session-format 产物逐字节全等;官方本版移除运行时 invariant 机制(各包 `./invariant` 导出 38→0、`dsh-invariants` 包退役)本插件零引用,并修复了「启停插件误删其它插件样式」。**唯一展示面破坏已双模式适配**:官方把 composer 统计行拆成 `activity`/`usage` 两个 dock 条目并删除行根类 `.root`,移动端规则除保留旧锚 `[class*="bOPqQW_root"]`(老宿主命中)外,新增官方新钩子 `[data-composer-stat]` 的同形约束与 ≤375px 字号阶梯(新宿主命中);0.2.0-rc.2 轮的模型弹窗分组标题 `MenuGroup` 双选适配继续有效,Client Inspect 挂起缺陷自 0.2.0-rc.2 起保持修复。rc.1 起新增的插件版本兼容门禁只读取 `peerDependencies`(本插件无该声明,不受门禁约束),并已在 DSH `0.2.1-alpha.1` 真机挂载运行。设置面、persistence/布局 seam 均按运行时能力探测走双形态,旧宿主上针对新结构的适配项天然不生效(纯展示,无功能损失)。注意:升级后写入的会话日志无法被旧版 DSH 读取,**备份不可跨版本降级恢复**。插件市场按 `package.json` 的 `engines.dsh` 区间判定兼容性(该字段是唯一的支持口径声明)。
385
+ **DSH 适配口径(alpha.1 基线)**:已适配 DSH `0.2.1-alpha.1`——会话格式 V4(V3 日志首读时由官方迁移为 `session.v4.jsonl.zstd` 代际文件,旧 `session.v3.jsonl.zstd` 按格式目录策略保留;详情视图自动归档系统事件)、`SettingsForms` 配置面(插件配置改由 Profile 的 `cordis.patch.yml` 承载,热更新走 `loader/volatile-update`;旧的 `settings.register` 在 0.1.5/0.1.6 上仍是权威来源,装在同一宿主上两者互不串台)、会话格式 V3 既有适配全部保留(`system/message` 入史、sessionPersistence handle 化、官方右栏、turn-process 对象化)、移动端底行触发钮双哈希兼容、子代理回合尾模型行 list 槽位自适应兼容、`plugins.bundle.config` 槽位注入、会话详情打开接入 `uiWorkspace` 降级链路。旧版 DSH(`>=0.1.1-rc.2`)保持兼容。alpha.1 轮曾将声明支持区间扩展到 `0.2.1-alpha.1`(当时 `engines.dsh` 上界 `<0.2.1-alpha.2`);0.2.1-alpha.1 源码级、npm 产物级(按 rolldown `//#region` 切片做逐源模块 sha256)与真机挂载审查(266 commits)确认:20 条既有接缝 17 条区域级逐字节相同、3 条均不触及本插件契约,9 个 slot key、25 个主题令牌、插件使用的 35 项官方 `data-*` 零丢失,宿主 RPC / `webServer` / 设置面 / persistence / session-format 产物逐字节全等;官方本版移除运行时 invariant 机制(各包 `./invariant` 导出 38→0、`dsh-invariants` 包退役)本插件零引用,并修复了「启停插件误删其它插件样式」。**唯一展示面破坏已双模式适配**:官方把 composer 统计行拆成 `activity`/`usage` 两个 dock 条目并删除行根类 `.root`,移动端规则除保留旧锚 `[class*="bOPqQW_root"]`(老宿主命中)外,新增官方新钩子 `[data-composer-stat]` 的同形约束与 ≤375px 字号阶梯(新宿主命中);0.2.0-rc.2 轮的模型弹窗分组标题 `MenuGroup` 双选适配继续有效,Client Inspect 挂起缺陷自 0.2.0-rc.2 起保持修复。rc.1 起新增的插件版本兼容门禁只读取 `peerDependencies`(本插件无该声明,不受门禁约束),并已在 DSH `0.2.1-alpha.1` 真机挂载运行。设置面、persistence/布局 seam 均按运行时能力探测走双形态,旧宿主上针对新结构的适配项天然不生效(纯展示,无功能损失)。注意:升级后写入的会话日志无法被旧版 DSH 读取,**备份不可跨版本降级恢复**。插件市场按 `package.json` 的 `engines.dsh` 区间判定兼容性(当前区间见下方 alpha.2 补充口径)。
386
+
387
+ **DSH 0.2.1-alpha.2 补充适配**:支持范围扩展至 `0.2.1-alpha.2`(`engines.dsh`:`>=0.1.1-rc.2 <0.2.1-alpha.3`)。子代理启动同时兼容新版 `startActivation(spec)` 与老版 `start` / `startContinuable`;移动端任务芯片兼容官方移除 `.QsffPG_root`、任务菜单改用 body portal 定位。已在 alpha.2 当前 Web 宿主验证插件 RPC、子代理路由 `available:true`、真实 `subagent` 和 `subagent_fork` 派生及各自的路由派发记录,旧版和新版接口单测均通过。移动端在 alpha.2 真实有后台任务的会话上验证 320/360/390/560px 泊位几何、561px 在流回退及 390px portaled 菜单两次开合无越屏;320px 下任务计数文字完全省略但图标/箭头及完整 `aria-label` 保留。**老宿主真机 UI 对照与不同标签数尚未实测**。
382
388
 
383
389
  **DSH 桌面端(DSH Desktop / Electron)口径**:桌面端复用同一份 Web 前端与同一张客户端插件图(`platform: web`),插件照常加载,绝大多数功能原样可用;语义不同的只有「重启」与「一键升级」。宿主由桌面应用托管,**重启一律不再 `process.exit(42)`**(桌面壳把任何非 0 退出判为 Host 崩溃,会弹原生「启动失败」对话框且不会自动重拉,官方也没有可编程重启 RPC)——「重启」按钮、对话里的 `/restart` 与备份恢复后的重启统一改为提示「请在桌面端退出应用后重新打开」;**一键升级在桌面端置灰**并指向应用自带的插件管理页(桌面端 CLI 不能启动/改写 `profiles/desktop`,升级端点直接短路返回 `desktop-managed-upgrade`,不产生任何 subprocess);健康诊断的「运行环境」行显示「由桌面端托管(Electron)」。判据是宿主进程内的 `process.versions.electron`(Windows 桌面版真机实测 `44.0.0`,同进程 `ELECTRON_RUN_AS_NODE=1`、stdin/stdout 均非 TTY);非 Electron 的桌面封装可用 `DSH_SERVICE_RUNTIME_ENV=desktop` 显式声明。任务通知在桌面端**可用**(权限 granted,真机已收到系统通知);唯一的边界是**点击通知唤不回窗口**(桌面端关窗=隐藏窗口,`dshDesktop` 无窗口 API,请用托盘唤回)。macOS / Linux 桌面端未验证。
384
390
 
@@ -416,7 +422,13 @@ pm2 start "dsh web --host 127.0.0.1" --name dsh-web
416
422
  <details>
417
423
  <summary><strong>额度卡片显示「凭据未配置」?</strong></summary>
418
424
 
419
- 点击卡片上的内联表单写入凭据:普通适配填 API key,CLIProxyAPI 填管理密钥(不是代理 key),小米 Token Plan 填控制台 Cookie。写入 DSH 凭据库后自动强制刷新;被进程环境变量遮蔽时宿主会拒绝写入,需改环境变量本身。
425
+ 点击卡片上的内联表单写入凭据:普通适配填 API key,CLIProxyAPI 填管理密钥(不是代理 key),小米 Token Plan 填控制台 Cookie。写入 DSH 凭据库后自动强制刷新;被进程环境变量遮蔽时宿主会拒绝写入,需改环境变量本身。凭据填错或需要轮换时,已配置的卡片在「配置」区同样有「修改凭据(已配置)」入口,可直接覆盖或清除已存值。
426
+ </details>
427
+
428
+ <details>
429
+ <summary><strong>CLIProxyAPI 卡片显示「已被 CLIProxyAPI 暂时封禁」?</strong></summary>
430
+
431
+ 管理密钥连续填错触发了 CPA 的 fail2ban:同一 IP 失败满 5 次封 30 分钟,封禁期间**正确密钥也会被拒**,且该状态不提供重填入口(密钥本身没问题)。等封禁到期(上游原话里带剩余时间),或更换 DSH 宿主机的出口 IP 后,点卡片「重试连接」即可恢复。封禁只作用于 `/v0/management` 管理面,走这台 CPA 的模型推理不受影响。
420
432
  </details>
421
433
 
422
434
  <details>
@@ -446,7 +458,7 @@ Step Plan 订阅没有 API-key 形态的查询接口,需要网页登录态令
446
458
  <details>
447
459
  <summary><strong>恢复备份会怎样?</strong></summary>
448
460
 
449
- 先做完整性检查并展示恢复预检计划,再由用户最终确认。提交前宿主会复检备份 SHA-256、当前目标指纹与运行中工作;任何变化都会中止,不会部分覆盖或重启。提交成功后会话目录整体替换,允许的配置文件按快照精确替换,profile 只覆盖 package.json(node_modules、凭据、附件不动)。受 Docker/systemd/pm2 等托管时自动重启;疑似终端手动启动时显示手动重启指引。删除备份同样需要两段式确认。
461
+ 先做完整性检查并展示恢复预检计划,再由用户最终确认。提交前宿主会复检备份 SHA-256、当前目标指纹与运行中工作;任何变化都会中止,不会部分覆盖或重启。提交成功后会话目录整体替换,允许的配置文件按快照精确替换,profile 按预检里的目标选择恢复(跨名映射默认只恢复 Profile 配置层、不覆盖本机插件清单,勾选后才覆盖;node_modules、凭据、附件不动)。受 Docker/systemd/pm2 等托管时自动重启;疑似终端手动启动时显示手动重启指引。删除备份同样需要两段式确认。
450
462
  </details>
451
463
 
452
464
  <details>
@@ -19,6 +19,20 @@ const MAX_META_BYTES = 4096
19
19
  // semver 形、长度封顶、且**必须含数字**——少了最后一条,未解析出 DSH 包的宿主
20
20
  // 写下的哨兵值 `unknown` 会被当成真版本显示出来。
21
21
  const META_VERSION_RE = /^(?=.*[0-9])[0-9A-Za-z][0-9A-Za-z.-]{0,63}$/
22
+ // 来源平台/架构:同样是**纯展示**信息,取值只接受 Node 平台串/架构串的字符集。
23
+ // 它不参与任何裁决,所以宽松解析(非法即 null)。
24
+ const META_PLATFORM_RE = /^[a-z][a-z0-9_-]{0,15}$/
25
+ // 跨平台提示:只读告警,绝不让一份可恢复的归档因为「目标平台会麻烦」被拒。
26
+ const MAX_NOTICES = 8
27
+ // Windows 传统 MAX_PATH(260)留 1 位给结尾 NUL 的保守口径。
28
+ const WINDOWS_PATH_LIMIT = 259
29
+ const CASE_INSENSITIVE_PLATFORMS = Object.freeze(['win32', 'darwin'])
30
+ // 目标 profile 名与备份侧同名规则同口径:单一路径段、长度封顶;`.`/`..` 另判。
31
+ const PROFILE_NAME_RE = /^[A-Za-z0-9._-]{1,64}$/
32
+ const MAX_PROFILE_MAPPINGS = 32
33
+ // Windows 上「文件被占用」的 errno 集合:rename/rm 撞到它们时给可操作错误码。
34
+ // 其他平台同样的 errno 更可能是权限问题,仍走通用 restore-failed。
35
+ const WINDOWS_LOCK_ERROR_CODES = Object.freeze(['EPERM', 'EBUSY', 'EACCES', 'ENOTEMPTY'])
22
36
  const PLAN_TTL_MS = 5 * 60 * 1000
23
37
  const MAX_COMPRESSED_BYTES = 512 * 1024 * 1024
24
38
  const MAX_EXPANDED_BYTES = 1024 * 1024 * 1024
@@ -116,7 +130,92 @@ function parseBackupMetadata(data) {
116
130
  if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null
117
131
  const version = typeof parsed.dshVersion === 'string' && META_VERSION_RE.test(parsed.dshVersion) ? parsed.dshVersion : null
118
132
  const createdAt = typeof parsed.createdAt === 'string' && !Number.isNaN(Date.parse(parsed.createdAt)) ? parsed.createdAt : null
119
- return { dshVersion: version, createdAt }
133
+ // 来源平台/架构(本版新增字段):缺失或非法一律 null,界面对「早于本版的归档」不显示来源。
134
+ const platform = typeof parsed.platform === 'string' && META_PLATFORM_RE.test(parsed.platform) ? parsed.platform : null
135
+ const arch = typeof parsed.arch === 'string' && META_PLATFORM_RE.test(parsed.arch) ? parsed.arch : null
136
+ return { dshVersion: version, createdAt, platform, arch }
137
+ }
138
+
139
+ // 跨设备/跨平台提示(纯函数,便于直接断言):输入是**目标机上的落盘相对路径**,
140
+ // 输出是稳定 code 的只读提示。三条口径:
141
+ // ① 来源平台已知且与目标不同 → 提示(信息级);
142
+ // ② 目标卷不区分大小写(win32/darwin 默认)时,仅大小写不同的条目会折叠到同一路径;
143
+ // ③ win32 上超长路径(经典 260 上限)会在写入时才炸,提前算出最长值告知。
144
+ // 任何一条都不改变 validForRestore——恢复能否进行仍只由完整性裁决。
145
+ function collectPlatformNotices(paths, options) {
146
+ const { platform, dshHome, sourcePlatform = null } = options
147
+ const notices = []
148
+ if (typeof sourcePlatform === 'string' && sourcePlatform !== '' && sourcePlatform !== platform) {
149
+ notices.push({ code: 'source-platform-differs', detail: sourcePlatform })
150
+ }
151
+ if (CASE_INSENSITIVE_PLATFORMS.includes(platform)) {
152
+ const folded = new Map()
153
+ for (const relative of paths) {
154
+ const key = relative.toLowerCase()
155
+ const existing = folded.get(key)
156
+ if (existing !== undefined && existing !== relative) {
157
+ notices.push({ code: 'target-case-collision', detail: relative.slice(0, 200) })
158
+ break
159
+ }
160
+ folded.set(key, relative)
161
+ }
162
+ }
163
+ if (platform === 'win32' && typeof dshHome === 'string') {
164
+ let longest = 0
165
+ for (const relative of paths) longest = Math.max(longest, dshHome.length + 1 + relative.length)
166
+ if (longest > WINDOWS_PATH_LIMIT) notices.push({ code: 'target-path-length', detail: String(longest) })
167
+ }
168
+ return notices.slice(0, MAX_NOTICES)
169
+ }
170
+
171
+ // 恢复提交阶段的失败归因:Windows 上 rename/rm 撞到占用类 errno 时给专属码,
172
+ // 让客户端能说「关掉占用方再试」,而不是笼统的 restore-failed。
173
+ function restoreFailureCode(error, platform) {
174
+ if (platform === 'win32' && WINDOWS_LOCK_ERROR_CODES.includes(error?.code)) return 'restore-files-locked'
175
+ return 'restore-failed'
176
+ }
177
+
178
+ function isSafeProfileName(value) {
179
+ return typeof value === 'string' && PROFILE_NAME_RE.test(value) && value !== '.' && value !== '..'
180
+ }
181
+
182
+ // 把归档里的 profile 路径按映射改写:`profiles/<source>/<file>` → `profiles/<target>/<file>`。
183
+ // 只认三段式白名单路径(校验阶段已保证),其余路径原样返回。
184
+ function remapProfilePath(path, mapping) {
185
+ if (mapping === undefined || mapping.size === 0) return path
186
+ const parts = path.split('/')
187
+ if (parts.length !== 3 || parts[0] !== 'profiles') return path
188
+ const target = mapping.get(parts[1])
189
+ return target === undefined || target === parts[1] ? path : `profiles/${target}/${parts[2]}`
190
+ }
191
+
192
+ // 请求映射的归一与校验。返回 `{ mapping }` 或 `{ error }`(稳定码,调用方转 domainError)。
193
+ // 省略请求即「逐条同名 + 覆盖 manifest」,与本轮之前的行为逐字一致。
194
+ function normalizeProfileMapping(requested, archived, localProfiles) {
195
+ const archivedNames = [...new Set(archived)].sort()
196
+ if (requested === undefined || requested === null) {
197
+ return { mapping: archivedNames.map((name) => ({ source: name, target: name, manifest: true })) }
198
+ }
199
+ if (!Array.isArray(requested) || requested.length > MAX_PROFILE_MAPPINGS) return { error: 'restore-mapping-invalid' }
200
+ const local = new Set(Array.isArray(localProfiles) ? localProfiles.filter(isSafeProfileName) : [])
201
+ const archivedSet = new Set(archivedNames)
202
+ const seenSources = new Set()
203
+ const seenTargets = new Set()
204
+ const mapping = []
205
+ for (const entry of requested) {
206
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) return { error: 'restore-mapping-invalid' }
207
+ const source = entry.source
208
+ const target = entry.target
209
+ if (!archivedSet.has(source) || !isSafeProfileName(target)) return { error: 'restore-mapping-invalid' }
210
+ // 目标名只允许宿主清单内的名字,或与源同名(保持「同名新建」的既有语义)。
211
+ if (target !== source && !local.has(target)) return { error: 'restore-mapping-invalid' }
212
+ if (entry.manifest !== undefined && typeof entry.manifest !== 'boolean') return { error: 'restore-mapping-invalid' }
213
+ if (seenSources.has(source) || seenTargets.has(target)) return { error: 'restore-mapping-invalid' }
214
+ seenSources.add(source)
215
+ seenTargets.add(target)
216
+ mapping.push({ source, target, manifest: entry.manifest === undefined ? source === target : entry.manifest })
217
+ }
218
+ return { mapping }
120
219
  }
121
220
 
122
221
  function validateEntry(path, type, data, state) {
@@ -248,6 +347,8 @@ function parseTar(expanded, options = {}) {
248
347
  validateEntry(path, type, payload, state)
249
348
  if (options.collectEntries === true) entries.push({ path, type, data: type === 'file' ? Buffer.from(payload) : undefined })
250
349
  else if (options.digestEntries === true) entries.push(type === 'file' ? { path, type, size: headerSize, sha256: createHash('sha256').update(payload).digest('hex') } : { path, type })
350
+ // 只取路径的轻量模式:跨平台提示要逐条目算目标路径,但不需要 payload。
351
+ else if (options.collectPaths === true) entries.push({ path, type })
251
352
  else entries.push(null)
252
353
  }
253
354
 
@@ -281,6 +382,10 @@ async function inspectArchive(source, options = {}) {
281
382
  // 界面优先显示后者;前者是廉价但可能与内容不符的镜像。
282
383
  nameVersion: typeof source.nameVersion === 'string' ? source.nameVersion : null,
283
384
  metadata: null,
385
+ // 形状恒定:坏归档也给 null / 空数组,客户端不必按存在性写防御分支。
386
+ sourcePlatform: null,
387
+ sourceArch: null,
388
+ notices: [],
284
389
  sections: emptySections(),
285
390
  issues: [],
286
391
  issueCount: 0,
@@ -302,7 +407,7 @@ async function inspectArchive(source, options = {}) {
302
407
  try { expanded = await gunzipArchive(compressed, { maxOutputLength: MAX_EXPANDED_BYTES }) } catch (error) {
303
408
  throw domainError(error?.code === 'ERR_BUFFER_TOO_LARGE' ? 'backup-size-limit' : 'backup-gzip-invalid')
304
409
  }
305
- const parsed = parseTar(expanded, options)
410
+ const parsed = parseTar(expanded, { ...options, collectPaths: true })
306
411
  return {
307
412
  report: {
308
413
  ...base,
@@ -313,6 +418,15 @@ async function inspectArchive(source, options = {}) {
313
418
  // 元数据非法/缺席时退回文件名里的版本段,保证「只要有一处记了版本」就看得见。
314
419
  metadata: parsed.metadata ?? null,
315
420
  dshVersion: parsed.metadata?.dshVersion ?? (typeof source.nameVersion === 'string' ? source.nameVersion : null),
421
+ // 来源平台/架构只做展示与提示;旧归档缺字段即 null。
422
+ sourcePlatform: parsed.metadata?.platform ?? null,
423
+ sourceArch: parsed.metadata?.arch ?? null,
424
+ // 跨平台提示(只读,不影响 validForRestore)。计划阶段会按映射后的目标路径重算。
425
+ notices: collectPlatformNotices(parsed.entries.map((entry) => entry.path), {
426
+ platform: options.platform ?? process.platform,
427
+ dshHome: options.dshHome,
428
+ sourcePlatform: parsed.metadata?.platform ?? null,
429
+ }),
316
430
  sections: parsed.sections,
317
431
  },
318
432
  parsed,
@@ -448,6 +562,28 @@ async function pathExists(path) {
448
562
  try { await lstat(path); return true } catch (error) { if (error?.code === 'ENOENT') return false; throw error }
449
563
  }
450
564
 
565
+ // 目标机会话目录的**只读诊断**探测:建临时文件 → 改名 → 删除。失败最可能的原因是
566
+ // 目标平台上有进程占用着会话目录(Windows 上宿主自身/同步盘/杀毒都可能持句柄),
567
+ // 也可能是权限不足;两种情况都值得在确认前告诉用户。sessions/ 不存在时跳过探测
568
+ // ——恢复本来就会新建它,此时「不可写」无从谈起。探测文件在返回前一定清理干净,
569
+ // 否则会污染紧随其后计算的目标指纹。
570
+ async function probeSessionsWritable(dshHome) {
571
+ const dir = join(dshHome, 'sessions')
572
+ if (!(await pathExists(dir))) return null
573
+ const probe = join(dir, `.dsh-service-probe-${randomUUID()}`)
574
+ const renamed = `${probe}.renamed`
575
+ try {
576
+ await writeFile(probe, '', { mode: 0o600 })
577
+ await rename(probe, renamed)
578
+ await unlink(renamed)
579
+ return null
580
+ } catch (error) {
581
+ await rm(probe, { force: true }).catch(() => {})
582
+ await rm(renamed, { force: true }).catch(() => {})
583
+ return typeof error?.code === 'string' ? error.code : 'probe-failed'
584
+ }
585
+ }
586
+
451
587
  async function writeJournal(path, value) {
452
588
  const temporary = `${path}.${randomUUID()}.tmp`
453
589
  try {
@@ -493,6 +629,8 @@ function publicPlan(plan) {
493
629
  reportSummary: plan.reportSummary,
494
630
  targets: plan.targets,
495
631
  consequences: plan.consequences,
632
+ // 跨平台提示(计划阶段按映射后路径重算):与报告同名同形,客户端优先用计划里的。
633
+ notices: plan.notices ?? [],
496
634
  previousInstanceId: plan.previousInstanceId,
497
635
  runtime: plan.runtime,
498
636
  }
@@ -508,6 +646,12 @@ export function createBackupIntegrity(options) {
508
646
  previousInstanceId,
509
647
  scheduleRestart = () => {},
510
648
  now = () => Date.now(),
649
+ // 目标平台:必须是「恢复实际发生的那台机器」的事实,默认取宿主进程平台。
650
+ // 只驱动只读提示与错误措辞,绝不参与归档裁决。
651
+ platform = process.platform,
652
+ // 目标机可选的 profile 名清单(宿主侧 readdir 结果);映射的目标必须落在其中
653
+ // 或与源同名(同名新建是既有语义)。
654
+ listProfiles = async () => [],
511
655
  } = options
512
656
  const plans = new Map()
513
657
  let recoveryError
@@ -545,14 +689,14 @@ export function createBackupIntegrity(options) {
545
689
  await prunePlans()
546
690
  const source = await resolveSource(id)
547
691
  if (source === undefined) return undefined
548
- return (await inspectArchive(source)).report
692
+ return (await inspectArchive(source, { platform, dshHome })).report
549
693
  }
550
694
 
551
695
  // tar 报「读取期间有变化」时的完整性兜底:解析归档并把它逐条目核对回暂存树。
552
696
  // 只有归档是暂存树的完整精确副本时才返回 true;任何缺失、截断或内容差异都判为不可发布。
553
697
  async function verifyArchivedTree(source, root) {
554
698
  await ensureRecovered()
555
- const inspected = await inspectArchive(source, { digestEntries: true })
699
+ const inspected = await inspectArchive(source, { digestEntries: true, platform, dshHome })
556
700
  if (!inspected.report.validForRestore || inspected.parsed === null) {
557
701
  const issue = inspected.report.issues?.[0]?.code
558
702
  throw domainError(issue || 'backup-archive-invalid')
@@ -561,7 +705,7 @@ export function createBackupIntegrity(options) {
561
705
  return true
562
706
  }
563
707
 
564
- async function prepareRestore(id) {
708
+ async function prepareRestore(id, requestedProfiles) {
565
709
  return withLock(async () => {
566
710
  await ensureRecovered()
567
711
  await prunePlans()
@@ -569,23 +713,57 @@ export function createBackupIntegrity(options) {
569
713
  if (activity?.hasActive === true) throw domainError('active-work')
570
714
  const source = await resolveSource(id)
571
715
  if (source === undefined) throw domainError('unknown-backup')
572
- const inspected = await inspectArchive(source, { collectEntries: true })
716
+ const inspected = await inspectArchive(source, { collectEntries: true, platform, dshHome })
573
717
  if (!inspected.report.validForRestore || inspected.parsed === null) throw domainError('backup-archive-invalid')
574
718
  // 归档可能带补丁而不带 manifest(外部工具所出):指纹与恢复操作都按并集处理,
575
719
  // 否则「只改了补丁的 profile」在 target 变更检测里是盲区。
576
- const profileNames = [...new Set([
720
+ const archivedNames = [...new Set([
577
721
  ...inspected.report.sections.profiles.items.map((item) => item.name),
578
722
  ...inspected.report.sections.profiles.patchFiles.map((item) => item.name),
579
723
  ])].sort()
580
- const patchProfileNames = inspected.report.sections.profiles.patchFiles.map((item) => item.name)
581
- const targetState = await fingerprintTargets(dshHome, profileNames)
724
+ const manifestSources = new Set(inspected.report.sections.profiles.items.map((item) => item.name))
725
+ const patchSources = new Set(inspected.report.sections.profiles.patchFiles.map((item) => item.name))
726
+ const localProfiles = await listProfiles().catch(() => [])
727
+ const normalized = normalizeProfileMapping(requestedProfiles, archivedNames, localProfiles)
728
+ if (normalized.error !== undefined) throw domainError(normalized.error)
729
+ // 「恢复什么」最终由归档决定:没带 manifest 的源不会有 manifest 操作,补丁同理。
730
+ const mapping = normalized.mapping.map((entry) => ({
731
+ source: entry.source,
732
+ target: entry.target,
733
+ manifest: entry.manifest === true && manifestSources.has(entry.source),
734
+ patch: patchSources.has(entry.source),
735
+ }))
736
+ const targetProfiles = [...new Set(mapping.map((entry) => entry.target))]
737
+ const remap = new Map(mapping.map((entry) => [entry.source, entry.target]))
738
+ // 同一份备份重复 prepare(用户改映射)时释放上一份计划:staging 是整棵归档树的
739
+ // 副本,不释放会随每次改选堆积磁盘。
740
+ for (const [existingId, existing] of [...plans]) {
741
+ if (existing.state === 'planned' && existing.source.id === source.id) {
742
+ existing.state = 'expired'
743
+ plans.delete(existingId)
744
+ await rm(existing.staging, { recursive: true, force: true })
745
+ }
746
+ }
747
+ const targetState = await fingerprintTargets(dshHome, targetProfiles)
582
748
  const planId = randomUUID()
583
749
  const staging = join(dshHome, 'backups', `.restore-plan-${planId}`)
584
750
  await materialize(inspected.parsed, staging)
751
+ // 跨平台提示按**映射后**的目标路径重算,再补一条会话目录可写探测(占用/权限)。
752
+ const notices = collectPlatformNotices(inspected.parsed.entries.map((entry) => remapProfilePath(entry.path, remap)), {
753
+ platform,
754
+ dshHome,
755
+ sourcePlatform: inspected.report.sourcePlatform ?? null,
756
+ })
757
+ const probeDetail = await probeSessionsWritable(dshHome)
758
+ if (probeDetail !== null) notices.push({ code: 'target-sessions-unwritable', detail: probeDetail })
585
759
  const preparedAt = now()
586
760
  const configPresent = new Set(inspected.report.sections.config.files.map((item) => item.name))
587
761
  const configRemove = []
588
762
  for (const name of CONFIG_FILES) if (!configPresent.has(name) && await pathExists(join(dshHome, name))) configRemove.push(name)
763
+ const manifestsReplaced = mapping.filter((entry) => entry.manifest).map((entry) => entry.target)
764
+ const patchesReplaced = mapping.filter((entry) => entry.patch).map((entry) => entry.target)
765
+ const crossName = mapping.filter((entry) => entry.source !== entry.target)
766
+ const manifestSkipped = crossName.filter((entry) => entry.manifest !== true && manifestSources.has(entry.source))
589
767
  const plan = {
590
768
  state: 'planned',
591
769
  planId,
@@ -596,28 +774,39 @@ export function createBackupIntegrity(options) {
596
774
  source: { id: source.id, name: source.name, sizeBytes: source.sizeBytes, sha256: inspected.report.source.sha256, dshVersion: inspected.report.dshVersion ?? null },
597
775
  sourceFingerprint: inspected.report.source.sha256,
598
776
  targetFingerprint: targetState.fingerprint,
599
- profileNames,
600
- patchProfileNames,
777
+ mapping,
778
+ targetProfiles,
779
+ notices: notices.slice(0, MAX_NOTICES),
601
780
  reportSummary: {
602
781
  entryCount: inspected.report.archive.entryCount,
603
782
  logicalBytes: inspected.report.archive.logicalBytes,
604
783
  sessions: inspected.report.sections.sessions,
605
784
  configFiles: inspected.report.sections.config.files.length,
606
- profiles: profileNames.length,
607
- profilePatches: patchProfileNames.length,
785
+ profiles: archivedNames.length,
786
+ profilePatches: patchSources.size,
608
787
  archiveFormat: inspected.report.archiveFormat,
609
788
  // 备份时的 DSH 版本随计划下发:恢复是不可逆动作,确认前必须让用户看清
610
789
  // 「这份快照来自哪个版本」——降级恢复读不动新格式会话,写清楚才不至于误点。
611
790
  dshVersion: inspected.report.dshVersion ?? null,
791
+ sourcePlatform: inspected.report.sourcePlatform ?? null,
612
792
  metadata: inspected.report.metadata ?? null,
613
793
  },
614
794
  targets: {
615
795
  sessions: { action: 'replace', currentBytes: targetState.bytes, newBytes: inspected.report.sections.sessions.bytes },
616
796
  config: { replace: inspected.report.sections.config.files.map((item) => item.name), remove: configRemove, newBytes: inspected.report.sections.config.bytes },
617
- profiles: { upsert: profileNames, patches: patchProfileNames, untouched: true, newBytes: inspected.report.sections.profiles.bytes },
797
+ // mapping 是恢复行为的唯一事实源;upsert/patches 是它的两个投影,供既有界面直接计数。
798
+ profiles: { mapping, upsert: manifestsReplaced, patches: patchesReplaced, untouched: true, newBytes: inspected.report.sections.profiles.bytes },
618
799
  },
619
800
  // 旧归档(v1,不含补丁层)恢复时显式告知「哪些文件没带」,不判损坏、不阻断。
620
- consequences: ['sessions-replaced', ...(configRemove.length > 0 ? ['config-files-removed'] : []), ...(profileNames.length > 0 ? ['profile-manifests-replaced'] : []), ...(patchProfileNames.length > 0 ? ['profile-patches-replaced'] : ['profile-patches-absent']), 'service-restart-required'],
801
+ consequences: [
802
+ 'sessions-replaced',
803
+ ...(configRemove.length > 0 ? ['config-files-removed'] : []),
804
+ ...(manifestsReplaced.length > 0 ? ['profile-manifests-replaced'] : []),
805
+ ...(crossName.length > 0 ? ['profile-mapped'] : []),
806
+ ...(manifestSkipped.length > 0 ? ['profile-manifest-skipped'] : []),
807
+ ...(patchesReplaced.length > 0 ? ['profile-patches-replaced'] : ['profile-patches-absent']),
808
+ 'service-restart-required',
809
+ ],
621
810
  previousInstanceId,
622
811
  runtime: { supervisorKind: runtimeEnv?.supervisorKind ?? null, manualStartLikely: runtimeEnv?.manualStartLikely === true },
623
812
  }
@@ -644,12 +833,12 @@ export function createBackupIntegrity(options) {
644
833
  if (activity?.hasActive === true) { await rm(plan.staging, { recursive: true, force: true }); throw domainError('active-work') }
645
834
  const source = await resolveSource(plan.source.id)
646
835
  if (source === undefined) { await rm(plan.staging, { recursive: true, force: true }); throw domainError('restore-source-changed') }
647
- const inspected = await inspectArchive(source)
836
+ const inspected = await inspectArchive(source, { platform, dshHome })
648
837
  if (!inspected.report.validForRestore || inspected.report.source.sha256 !== plan.sourceFingerprint) {
649
838
  await rm(plan.staging, { recursive: true, force: true })
650
839
  throw domainError('restore-source-changed')
651
840
  }
652
- const targetState = await fingerprintTargets(dshHome, plan.profileNames)
841
+ const targetState = await fingerprintTargets(dshHome, plan.targetProfiles)
653
842
  if (targetState.fingerprint !== plan.targetFingerprint) {
654
843
  await rm(plan.staging, { recursive: true, force: true })
655
844
  throw domainError('restore-target-changed')
@@ -663,15 +852,17 @@ export function createBackupIntegrity(options) {
663
852
  }
664
853
  await addOperation(join(dshHome, 'sessions'), join(plan.staging, 'sessions'), 'sessions')
665
854
  for (const name of CONFIG_FILES) await addOperation(join(dshHome, name), join(plan.staging, 'config', name), `config/${name}`)
666
- for (const name of plan.profileNames) {
667
- await addOperation(join(dshHome, 'profiles', name, 'package.json'), join(plan.staging, 'profiles', name, 'package.json'), `profiles/${name}/package.json`)
855
+ // profile 操作完全由计划的 mapping 派生:源决定读哪份暂存文件,目标决定写到哪里。
856
+ for (const entry of plan.mapping ?? []) {
857
+ if (entry.manifest === true) {
858
+ await addOperation(join(dshHome, 'profiles', entry.target, 'package.json'), join(plan.staging, 'profiles', entry.source, 'package.json'), `profiles/${entry.target}/package.json`)
859
+ }
668
860
  // 补丁层白名单恢复。归档没带(旧版 v1 所出)时**不登记**这一步:登记了就代表
669
861
  // 「按快照覆盖」,operation 循环会先把目标侧 patch 挪走、再因 staged 缺席而不回填,
670
862
  // 等于用一份对配置毫无意见的旧归档静默删掉用户当前配置。缺席只记入计划报告
671
863
  // (consequences: profile-patches-absent),不改成删除。
672
- const stagedPatch = join(plan.staging, 'profiles', name, 'cordis.patch.yml')
673
- if (await pathExists(stagedPatch)) {
674
- await addOperation(join(dshHome, 'profiles', name, 'cordis.patch.yml'), stagedPatch, `profiles/${name}/cordis.patch.yml`)
864
+ if (entry.patch === true) {
865
+ await addOperation(join(dshHome, 'profiles', entry.target, 'cordis.patch.yml'), join(plan.staging, 'profiles', entry.source, 'cordis.patch.yml'), `profiles/${entry.target}/cordis.patch.yml`)
675
866
  }
676
867
  }
677
868
  const journalPath = join(dshHome, JOURNAL_FILE)
@@ -702,7 +893,7 @@ export function createBackupIntegrity(options) {
702
893
  } catch (_) {
703
894
  throw domainError('recovery-required')
704
895
  }
705
- throw domainError('restore-failed')
896
+ throw domainError(restoreFailureCode(error, platform))
706
897
  }
707
898
 
708
899
  // 桌面端(electronShell)与终端手动启动同理:没有东西会把 Host 拉起来,恢复完成后不调度
@@ -725,4 +916,4 @@ export function createBackupIntegrity(options) {
725
916
  return { inspectBackup, verifyArchivedTree, prepareRestore, commitRestore, dispose }
726
917
  }
727
918
 
728
- export { CONFIG_FILES, PLAN_TTL_MS }
919
+ export { CONFIG_FILES, PLAN_TTL_MS, collectPlatformNotices, normalizeProfileMapping, restoreFailureCode }
package/backup-routes.js CHANGED
@@ -23,6 +23,7 @@ export function createBackupRoutes({
23
23
  formatBackupTimestamp,
24
24
  importBackup,
25
25
  listBackups,
26
+ listLocalProfiles,
26
27
  name,
27
28
  rpcFailure,
28
29
  }) {
@@ -92,7 +93,22 @@ export function createBackupRoutes({
92
93
  } },
93
94
  'backup-restore-prepare': { feature: 'backupMaintenance', audit: true, handle: async (payload, rpcEndpoint) => {
94
95
  try {
95
- return { ok: true, value: await backupIntegrity.prepareRestore(payload?.id) }
96
+ // 可选的 profile 映射:省略即「逐条同名 + 覆盖 manifest」(与既有行为一致)。
97
+ // 目标名只能是宿主清单里的名字或与源同名,判定都在宿主侧。
98
+ return { ok: true, value: await backupIntegrity.prepareRestore(payload?.id, payload?.profiles) }
99
+ } catch (error) {
100
+ return rpcFailure(error)
101
+ }
102
+
103
+ } },
104
+ // 目标 profile 清单(P1):恢复要落到本机哪个 profile 由用户在**宿主给出的清单内**选,
105
+ // 浏览器只回传名字、不传路径。本机没有 profiles 目录时返回空清单(仍可同名新建)。
106
+ 'backup-target-profiles': { feature: 'backupMaintenance', handle: async (payload, rpcEndpoint) => {
107
+ try {
108
+ const profiles = await listLocalProfiles(dshHome)
109
+ const declared = process.env.DSH_PROFILE
110
+ const active = typeof declared === 'string' && profiles.some((item) => item.name === declared) ? declared : null
111
+ return { ok: true, value: { profiles, active } }
96
112
  } catch (error) {
97
113
  return rpcFailure(error)
98
114
  }