dsh-plugin-backup
English | 中文
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
# 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.
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
- Enable sync on the settings page and fill in
owner/repo (a full URL also works),
the branch, and the subdirectory.
- 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.
- 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 — 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.
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.
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 peers it cannot satisfy. Remove and re-add the plugin, or grant a one-off exact-version exemption with as a stopgap |
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 , for instance.
Both and activate against a , so either mistake fails the suite instead of the running app.
License
MIT