Skip to content
dsh.fish
Bundle

dsh-plugin-backup

DSH 备份插件:分类备份 DSH 设置/技能/插件/会话/存储,支持验证、恢复、下载与 GitHub 同步

Source
shiinakaze
License
MIT
Updated
Updated 7 days ago

Readme

# dsh-plugin-backup

English | [中文](README.zh.md)

A backup plugin for DeepSeek Harness: it packages `$DSH_HOME` into verifiable,
restorable, downloadable archives by category, and can mirror those backups to
GitHub. Everything is available from three faces that share one engine and one
single-run lock: the **Web settings page**, the **model tool surface**, and a
**CLI session**.

- Naming: `dsh-backup-<YYYYMMDD>-<HHmmss>`
- Categories: `settings`, `skills`, `plugins`, `sessions`, `storages`
- Per backup: **download / verify / restore / delete**
- Settings: **backup path**, **backup interval**, per-category switches, retention count,
  verify-after-create, per-category archive ceiling, GitHub sync
- No build step and no third-party runtime dependency: the host half is plain ESM,
  the browser half is a hand-writable CommonJS factory the shell loads as-is

## Install

```powershell
# local checkout (development)
dsh plugin --profile web add file:C:/path/to/dsh-plugin-backup

# or from GitHub
dsh plugin --profile web add github:shiinakaze/dsh-plugin-backup
```

The Desktop app manages its own `desktop` profile: install through its plugin
manager (or `dsh plugin --profile desktop add …`) and restart the app.

`dsh plugin add` both records the dependency and appends the package name to
`dsh.profile.bundles` (one composition layer). Reload or restart the Web
interface afterwards, then open **Settings → Data backup**.

> **Runtime support.** The plugin targets the `@deepseek-ai/dsh` 0.1.7 and 0.2.0
> lines: `peerDependencies` declare `^0.1.7-rc.2 || ^0.2.0-rc.1` for `dsh-tools`
> and `dsh-typert-protocol`, and `engines.dsh` mirrors that window. This matters
> on 0.2.0, whose boot performs a compatibility preflight against the running
> runtime and **refuses to install or mount a bundle whose dsh peers it cannot
> satisfy** — an exact pin to one rc would be rejected. The two branches are not
> cosmetic: package managers resolve with npm's prerelease rule, under which a
> prerelease is admitted only when some comparator pins its exact version tuple,
> so one `>=0.1.7-rc.2 <0.3.0` window silently drops `0.2.0-rc.1` and
> `0.2.0-rc.2` and fails `npm install` with `ERESOLVE`. Keep the window in
> `package.json` in step with the APIs the plugin actually uses.

> A `file:` install is a **snapshot**: the profile keeps its own copy, so after
> changing this package's source you must `dsh plugin --profile web remove <name>`
> and add it again. The measured verification log lives in
> [docs/verify.md](docs/verify.md).

> This is a DSH plugin, not a plain dependency: the host half injects the `tools`
> service declared in its schema, and the browser half is discovered through the
> `dsh.client` declaration in `package.json`. Without `$DSH_HOME` the plugin falls
> back to `~/.dsh`; in a non-Web profile (no `webServer` service) only *download*
> is unavailable — every other feature and tool still works.

## The page

Settings → **Data backup**:

| Block | Contents |
|---|---|
| Backup settings | Backup path (empty = `C:\Users\<user>\dsh_backups` on Windows, `~/dsh_backups` elsewhere; `~` and `${DSH_HOME}` are expanded), interval (off / 15 min / hourly / 6 h / daily / weekly), backups to keep, verify right after creating |
| Backup categories | The **selection** for a run: check the categories to include, with select-all / select-none; the button names the current choice ("Back up now (3 categories)") and is disabled with a hint while nothing is selected |
| Existing backups | Per row: id, creation time, file count, size, category tags, last verdict; actions are a **download** menu (one category or the manifest), **verify**, **preview restore**, **restore**, and **delete** (restore and delete take a second click to confirm; delete is the destructive-colored one) |
| GitHub sync | Enable switch, `owner/repo`, branch, subdirectory, token environment variable, whether a token was detected, and **push / pull** |

"Back up now" covers only the checked categories; saving the settings also makes that
selection the default for scheduled runs.

Settings are written to the plugin's own user layer at
`$DSH_HOME/dsh-plugin-backup/settings.json` (`0600`); the deployment baseline stays
in the profile's `cordis.patch.yml` and the two are shallow-merged (user wins).

## Layout on disk

```
<backupPath>/
  dsh-backup-20260930-141205/
    manifest.json           # schema version, host facts, per-category files/bytes/sha256/archive, last verdict
    settings.tar.gz         # one gzipped USTAR archive per category
    skills.tar.gz
    plugins.tar.gz
```

The manifest is written last and atomically, so "a directory with a manifest" is a
complete backup. Intermediate files carry a `.part` suffix while a run is in
flight; residue from a crash is cleaned up or ignored at the start of the next run.

## Categories and exclusions

| key | Sources (relative to `$DSH_HOME`) | Default |
|---|---|---|
| `settings` | `settings.yaml*`, `cordis.patch.yml*`, `.credentials.yaml*`, `.anonymous-user-id`, `dsh-skill-hub.json`, `skills-market-sync.json` | on |
| `skills` | `skills-management/**` | on |
| `plugins` | `profiles/*/package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `cordis.patch.yml*`, plus `plugins/**` | on |
| `sessions` | `sessions/**` | off |
| `storages` | `storages/**` | off |

Always excluded: `node_modules` and `.git*` at any depth, symbolic links, and the
backup root itself — so keeping the store inside `$DSH_HOME` never recurses.

### Measured on a real harness home (`settings` + `plugins` + `skills`)

| Category | Files | Raw | Archive | Duration |
|---|---|---|---|---|
| `settings` | 5 | 2.0 KB | 1.3 KB | < 0.1 s |
| `plugins` | 39 | 1.24 MB | 1.12 MB | 0.2 s |
| `skills` | 29 356 | 342.5 MB | 144.8 MB | ≈ 50 s |

Creation and verification are streaming (flat memory; measured RSS went 57 MB →
158 MB), so a category is never held in memory. That is why the default ceiling is
**512 MiB**: a realistic skills tree already passes 100 MiB compressed. Raise
`maxArchiveBytes` in the settings if yours is larger, or leave `skills` out and
back it up by hand. With retention 20 and `skills` enabled the store can reach ~3 GB.

## GitHub sync

1. Enable sync on the settings page and fill in `owner/repo` (a full URL also works),
   the branch, and the subdirectory.
2. Token resolution, in order:
   - an environment variable — `DSH_BACKUP_GITHUB_TOKEN` by default (renameable in settings);
   - the state file `$DSH_HOME/dsh-plugin-backup/github-token.json` holding `{"token": "…"}`, mode `0600`.
   The token is read from nowhere else and never written to config, logs, or tool output.
3. **Push** uploads file by file through the Contents API, archives first and
   `manifest.json` last; a backup the remote already holds with the same content is
   skipped. **Pull** downloads only the backups missing locally and verifies every
   archive against the sha256 in its manifest — a mismatch discards that backup and
   reports why.

Remote layout: `<subdir>/<backupId>/{manifest.json,<category>.tar.gz}`.

## Model tools

| Tool | Parameters | Purpose |
|---|---|---|
| `backup_run` | `categories?`, `label?` | Create a backup now |
| `backup_list` | `limit?` | List backups, newest first |
| `backup_verify` | `id` | Recompute sha256 and re-read the tar structure, per category |
| `backup_restore` | `id`, `categories?`, `dryRun?`, `force?` | Restore; `dryRun` only reports the plan |
| `backup_delete` | `id`, `force?` | Delete; the newest backup is protected while an older one exists |
| `backup_sync` | `direction`, `ids?` | Push to or pull from GitHub |

A typical loop: `backup_run` before a risky change, then `backup_verify` and
`backup_restore` afterwards. `backup_restore` verifies first and refuses a corrupt
backup unless `force` is set.

## Safety boundaries

| Situation | Behaviour |
|---|---|
| Path traversal | Backup ids must match `^dsh-backup-\d{8}-\d{6}$`; archive entries refuse absolute paths, drive letters, and `..` |
| Corruption | `verify` names the category and the expected/actual sha256; `restore` refuses by default and, under `force`, skips that category and reports it |
| Irreversible overwrite | Before a restore, every file about to be overwritten is kept beside itself as `<name>.pre-restore` |
| Concurrency | One single-run lock (`<backupPath>/.lock`, stale after 30 minutes); a conflict returns `busy` instead of writing concurrently |
| Mis-deletion | While an older backup exists, deleting the newest needs `force`; deleting the last one does not |
| Runaway size | A category archive past `maxArchiveBytes` aborts the run and removes the partial archive |
| Token leakage | The token lives only in the environment or the `0600` file; output only ever shows repository paths |
| Download links | Single-use download tickets (24 random bytes, 120 s expiry, consumed on first use) served by `GET /dsh-plugin-backup/download?token=…` |

The download route itself is not session-authenticated (the ticket *is* the
credential), so keep the Web server bound to loopback and do not expose it to an
untrusted network.

## Development

> **Building or porting another plugin?** See the DSH plugin development guide:
> [docs/plugin-development-guide.md](docs/plugin-development-guide.md) — Desktop 0.2.0
> runtime anatomy, the peer-compatibility preflight, a six-step adaptation/verification
> flow with tested commands, the two-face host/Typert/browser contract cheatsheet, the
> 0.1.7→0.2.0 API diff table, and a cross-version gotcha list.

```powershell
npm test          # 50 cases: tar / manifest / engine / plugin mount / browser half / package contract
node --check index.js
node --check client.js
```

The suite needs no DSH runtime running, yet it still checks against the **real
official packages**:

- `@deepseek-ai/dsh-typert-loader`'s `validateTypertManifest` validates the host descriptors;
- the real `TypertRegistry` registers all nine endpoints;
- the real `dsh-tools` `defineTool` compiles the six tool schemas;
- a real `@deepseek-ai/cordis` root mounts the plugin and drives the download route over real archives;
- the browser half is rendered through a module-loader shim plus a minimal hooks runtime, with the
  client descriptor table compared field by field against the host contribution.

```powershell
node test/local/measure-real-home.local.mjs settings plugins skills
node test/local/remount.local.mjs
```

The first script backs up the **real** `$DSH_HOME`, verifies it, dry-runs a restore,
and cleans up, which is how the size and duration table above was measured. The
second reproduces the repeated mount a profile recompose performs, to show the
service, tools, and download route neither leak nor break.

### Structure

```
index.js                 host plugin: Config schema, apply, download route
typert.js                host Typert descriptors + BackupService (remote method bodies)
lib/tools.js             the six backup_* tools
client.js                browser half: settings page + remote mount
lib/core/{tar,manifest,plan,store,settings,github,engine,util}.js
test/*.test.mjs          offline suite; test/local/ holds the measurement script
```

## Troubleshooting

| Symptom | Action |
|---|---|
| No "Data backup" section | Check the package is in `dsh.profile.bundles`, reload the page, and look for a FAILED fiber in the startup output |
| The page says the backup path is unavailable | The path is not writable or a file occupies it; pick a usable directory (the default is `C:\Users\<user>\dsh_backups`) and save |
| `busy` | Another run holds the lock (the schedule or another session); wait, or delete `<backupPath>/.lock` |
| `too-large` | Raise `maxArchiveBytes`, or exclude the `skills` category |
| Restore refused as corrupt | Run `backup_verify` to see the failing category; use a newer backup if the archive really is damaged |
| `github-auth` | No token, or it lacks `contents: write` on the target repository |
| Download does nothing | The ticket expires after 120 s; click download again |
| `method "backup/remove" conflicts with its namespace service` | A 0.1.1-and-earlier build issue: the client carrier's namespace service owns `remove`. From 0.1.2 the delete endpoint is named `deleteBackup`; avoid that reserved set when extending this plugin |
| `cannot get property "…" without inject` | A 0.1.2-and-earlier build issue: a Cordis service was read as a property without injecting it. From 0.1.3 the plugin uses `ctx.get(...)` and `ctx.inject(...)` child fibers; restart `dsh web` after upgrading |
| **"Save settings" blanks the whole settings page** (until the page is reloaded) | A 0.1.3-and-earlier build issue: the save consumed the host's `Preferences` answer (no `rows`/`total`/`state`) as a full page snapshot, so the render threw — and the slot's error boundary retires this section for good. From 0.1.4 the save re-reads the snapshot and the section carries its own payload normalization and error boundary; reload the page after upgrading |
| Desktop refuses the install: *"Plugin dsh-plugin-backup@… is incompatible with dsh 0.2.0-rc.2: peerDependencies …"* | A 0.1.4-and-earlier packaging issue: the dsh peers were pinned to `0.1.7-rc.2`, and 0.2.0's boot preflight rejects a bundle whose `@deepseek-ai/dsh-*` peers it cannot satisfy. Remove and re-add the plugin, or grant a one-off exact-version exemption with `dsh plugin allow-version` as a stopgap |
| `npm install` fails with `ERESOLVE … peerOptional @deepseek-ai/dsh-tools@">=0.1.7-rc.2 <0.3.0"` on a 0.2.0-rc host | A 0.2.0-only packaging issue: that single window reads as covering both lines, but npm admits a prerelease only when a comparator pins its exact version tuple, so `0.2.0-rc.1`/`0.2.0-rc.2` were excluded and the peer conflict was raised against the host's own copy. From 1.0.0 the peers read `^0.1.7-rc.2 \|\| ^0.2.0-rc.1`, which passes both npm's rule and the runtime preflight; upgrade, or fall back to `pnpm`/`dsh plugin add`, which tolerated the old range |

## Design notes

- **Why backups default to the user profile**: `C:\Users\<user>\dsh_backups` on Windows
  (`~/dsh_backups` elsewhere) decouples them from the harness home, so resetting or moving
  `$DSH_HOME` cannot take the backups with it, and the folder is easy to open by hand.
- **Why settings live in the plugin's own `settings.json`**: the backup path and
  interval are runtime preferences; rewriting the profile's YAML patch is riskier.
  A user layer over the deployment baseline is easier to diagnose.
- **Why one archive per category**: a single category can be restored, downloaded,
  or pushed on its own, and `sessions` ships off by default because it is the largest.
- **Why USTAR and the Typert descriptors are hand-written**: the plugin pulls in no
  third-party runtime dependency and needs no build after install; the descriptors
  match the official generated shape and are validated by the official loader and registry.
- **Why the wire method is `deleteBackup`, not `remove`**: the client carrier's namespace
  service already owns `remove`, `has`, `install`, `installDirect`, `installScoped`,
  `assertMethodAvailable`, plus `ctx`/`name`/`namespace`/`methods`/`empty`/`invokeRemote`,
  and a remote method with one of those names is refused at mount time
  (`method "backup/remove" conflicts with its namespace service`). An assertion in
  `test/plugin.test.mjs` pins that reserved set.
- **Services are read with `ctx.get(...)`, never as properties**: a Cordis `ctx` is a proxy,
  so a property read such as `ctx.webServer`, `ctx.tools`, or `ctx.remote.backup` is only
  legal inside a fiber that injected that name; otherwise it throws
  `cannot get property "…" without inject` after activation. Therefore:
  - optional services are never declared in `inject`. They are awaited through a
    `ctx.inject([...], cb)` child fiber instead. `webServer` is the telling case: in a real
    boot it becomes available about 0.8 s after this plugin activates, so reading it directly
    in `apply` silently lost the download route (and with it the whole page state). The tool
    registry attaches the same way, and only when it is actually there.
  - every other read goes through `ctx.get(name)`, the without-inject accessor — the tool
    registry and the mounted `remote.backup`, for instance.
  Both `test/plugin.test.mjs` and `test/client.test.mjs` activate against a **real Cordis
  context**, so either mistake fails the suite instead of the running app.

## License

MIT

Install

dsh plugin --profile web add github:shiinakaze/dsh-plugin-backup

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source