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:
"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
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)
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
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
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
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