@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 +20 -8
- package/README.md +20 -8
- package/backup-integrity.js +215 -24
- package/backup-routes.js +17 -1
- package/client.js +1 -1
- package/index.js +110 -35
- package/package.json +2 -2
- package/quota-adapters.js +39 -3
- package/quota-routes.js +13 -7
- package/screenshots/backup-management.png +0 -0
- package/screenshots/backup-management_en.png +0 -0
- package/screenshots/configuration.png +0 -0
- package/screenshots/configuration_en.png +0 -0
- package/screenshots/health-diagnostics.png +0 -0
- package/screenshots/health-diagnostics_en.png +0 -0
- package/screenshots/mobile-adaptation.png +0 -0
- package/screenshots/mobile-adaptation_en.png +0 -0
- package/screenshots/model-usage.png +0 -0
- package/screenshots/model-usage_en.png +0 -0
- package/screenshots/overview.png +0 -0
- package/screenshots/overview_en.png +0 -0
- package/screenshots/plugin-config.png +0 -0
- package/screenshots/plugin-config_en.png +0 -0
- package/screenshots/quota-lookup.png +0 -0
- package/screenshots/quota-lookup_en.png +0 -0
- package/screenshots/session-manager.png +0 -0
- package/screenshots/session-manager_en.png +0 -0
- package/screenshots/subagent-model.png +0 -0
- package/screenshots/subagent-model_en.png +0 -0
package/README.en.md
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
<em>DeepSeek Harness (DSH) Web 服务控制与运维插件。</em>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
-
[](package.json)
|
|
13
13
|
[](LICENSE)
|
|
14
|
-
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
15
15
|
[](https://cordis.moe/)
|
|
16
16
|
[](https://github.com/gehennawu/dsh-service)
|
|
17
17
|
[](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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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 & operations plugin for DeepSeek Harness (DSH) Web.</em>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
-
[](package.json)
|
|
13
13
|
[](LICENSE)
|
|
14
|
-
[](https://github.com/deepseek-ai/deepseek-harness)
|
|
15
15
|
[](https://cordis.moe/)
|
|
16
16
|
[](https://github.com/gehennawu/dsh-service)
|
|
17
17
|
[](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
|
-
-
|
|
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
|
-
-
|
|
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
|
|
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
|
|
461
|
+
先做完整性检查并展示恢复预检计划,再由用户最终确认。提交前宿主会复检备份 SHA-256、当前目标指纹与运行中工作;任何变化都会中止,不会部分覆盖或重启。提交成功后会话目录整体替换,允许的配置文件按快照精确替换,profile 按预检里的目标选择恢复(跨名映射默认只恢复 Profile 配置层、不覆盖本机插件清单,勾选后才覆盖;node_modules、凭据、附件不动)。受 Docker/systemd/pm2 等托管时自动重启;疑似终端手动启动时显示手动重启指引。删除备份同样需要两段式确认。
|
|
450
462
|
</details>
|
|
451
463
|
|
|
452
464
|
<details>
|
package/backup-integrity.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
581
|
-
const
|
|
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
|
-
|
|
600
|
-
|
|
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:
|
|
607
|
-
profilePatches:
|
|
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
|
-
|
|
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: [
|
|
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.
|
|
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
|
-
|
|
667
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|