# dsh-safe · Startup Fuse for dsh [中文](./README.md) | English > **Channel applicability (read this first)**: before dsh **0.1.6** (npm `latest` is still 0.1.5-rc.3), any plugin failure aborts the whole boot, so dsh-safe's **auto-quarantine** still applies on that channel. From dsh **0.1.6** on (`next` / `alpha`, including 0.1.7-rc.x) only required entries are fatal and any other entry failure merely warns while startup succeeds — dsh-safe then **writes nothing** and only performs a **read-only inspection** that lists the inactive plugins, their reasons and the repair entry point. The wrapper picks the mode automatically; there is nothing to configure. When a community plugin of DeepSeek Harness (DSH) is incompatible with the dsh runtime, dsh **before 0.1.6** makes `dsh web` **fail to boot entirely** — the loader flattens all patch layers into a single load tree, so if any plugin fails to import, throws inside `apply`, or times out waiting for an injected service, the boot audit rejects the whole tree and the process exits. The only remedy was manually editing `cordis.patch.yml` to disable the broken plugin. **dsh-safe automates that manual step**: it wraps `dsh`, identifies the offending plugin from the startup error, sets the matching row to `disabled: true` in the profile patch (recording it in a quarantine ledger), and retries automatically. A broken plugin only breaks itself; dsh boots as usual. From dsh **0.1.6** on, upstream changed the policy (`auditStartupEntries` in `packages/boot/app-boot`): only the global required entries (`agent-loop`, `webserver`, `modules`, `connection`, `headless-runner`, `acp`, `sdk-jsonrpc-server`) are fatal, while **every other entry failure just prints a warning and boot continues**. Plugins therefore go *silently missing*: the feature is gone, `$DSH_HOME/logs/startup-*.log` is only written for fatal failures, and that stderr warning is the only clue (invisible when dsh is started from a GUI). dsh-safe switches to a **read-only inspection** there: it turns `warning: N entries did not activate` into a list plus a repair hint, and **never touches the patch, the ledger, or retries**. ## Installation ```bash npm install -g @hyzyn/dsh-safe ``` Requires Node >= 20 and a local `dsh` command. Zero runtime dependencies. ## Quick Start Just swap `dsh` for `dsh-safe` — `-u` (update-and-boot) is recommended: when dsh has a new version it upgrades and restores quarantined plugins first; when dsh is already latest it behaves exactly like a plain start: ```bash dsh-safe -u web # recommended: update then boot (with auto-quarantine) dsh-safe web # no update check, boot with auto-quarantine dsh-safe --profile tui --patch ./extra.yml ``` `-u` adds one version check per boot (needs network; on check failure it just warns and boots anyway) — offline or scripted environments can use the second line. Sample output (shown with a zh locale: a broken plugin is quarantined, then startup retries): ``` Error: dsh: plugin tree failed to load: failed to apply loader entry smoke-broken (@smoke/broken-impl): Cannot find package '@smoke/broken-impl' ... [dsh-safe] 已禁用 @smoke/broken-impl (id: smoke-broken) → /Users/me/.dsh/profiles/web/cordis.patch.yml 原因: Error: failed to import loader entry smoke-broken (@smoke/broken-impl): Cannot find package … [dsh-safe] 重试启动… ``` ## Commands & Options ### Subcommands | Command | Description | | --- | --- | | `dsh-safe ` | wrap and run dsh (swap `dsh` for `dsh-safe`) | | `dsh-safe -u [update options] [dsh args…]` | upgrade dsh and dsh-safe itself first (skip if latest), then boot in wrap mode; with no dsh args at all it only upgrades and does not boot; `--update` is an alias | | `dsh-safe update [options]` | upgrade only, no boot — options below | | `dsh-safe list [--profile ] [--json]` | show quarantined plugins (`--json` outputs structured JSON; defaults to all profiles) | | `dsh-safe doctor` | environment check: versions, DSH_HOME, profiles, ledger, patch health | | `dsh-safe restore [--profile ] (--id \| --all) [--dry-run]` | re-enable auto-disabled plugins (omit `--profile` to cover every profile in the ledger) | | `dsh-safe explain [id] [--profile \| --file ]` | interpret failure info with AI: an `id` interprets that quarantine record (with repair advice); defaults to the last failure log, falling back to the ledger; `--file`/stdin read any log (needs `DSH_SAFE_AI_KEY`) | | `dsh-safe repair [id] [--all] [--profile ] [--to ] [-y] [--dry-run]` | reinstall/upgrade a quarantined plugin and auto-restore it (failures a reinstall/upgrade may fix: package resolution, export mismatches; installs via `dsh plugin`'s pnpm channel); duplicate mounts support auto-dedup (interactively pick which source to keep); well suited to being run by the AI agent in the web UI | | `dsh-safe help` (`-h` / `--help`) | show help | | `dsh-safe --version` (`-V`) | show version | Every short flag has an equivalent long form (`-u` = `--update`, `-y` = `--yes`, `-h` = `--help`, `-V` = `--version`); single letters use `-`, words use `--`. ### Wrapper-mode options (must come before the first positional argument) | Option | Description | | --- | --- | | `--dry-run` | Parse and report only; no files are modified | | `--max-retries ` | Max startup retries after an auto-quarantine (default 2; `0` means pass through without quarantining) | | `--allow-first-party` | Allow auto-disabling first-party `@deepseek-ai/*` plugins and removing official bundles during duplicate dedupe (skipped by default; handle manually) | | `--exclude ` | Quarantine exclusion list (repeatable) — matched rows are never auto-disabled; can also live in `config.json` | ### update / -u options (after `-u` or `update`; wrapper flags before the dsh args still apply) | Option | Description | | --- | --- | | `-y` / `--yes` | Skip the upgrade confirmation (required in non-interactive terminals) | | `--to ` | Target dsh version (a dist-tag such as `next` / `alpha` works too), also how you roll back (explicit downgrades allowed); dsh-safe itself always upgrades to the latest | | `--check` | Report only whether and to what it would upgrade (including any gap beyond `latest`); no prompt, no install, no boot. Needs no `-y` in non-interactive environments | | `--self` | Update dsh-safe itself only; dsh and quarantine state untouched | | `--no-restore` | Do not auto-restore quarantined plugins after upgrading dsh | | `--no-verify` | Skip the post-upgrade parser self-check (boots the new dsh with a throwaway profile to confirm error recognition still works) | | `--pm ` | Force the package manager (auto-detected by default) | ### Environment variables | Variable | Description | | --- | --- | | `DSH_SAFE_LANG=zh\|en` | Force message language (defaults to `LC_ALL` / `LC_MESSAGES` / `LANG` / `LANGUAGE`) | | `DSH_SAFE_NO_UPDATE_CHECK=1` | Disable the boot-time update report (the status + non-`latest` channel-gap lines that `-u ` prints on every start, plus the at-most-daily dsh-safe new-version notice); explicit `update` / `--check` are unaffected | | `DSH_HOME` | dsh home directory (dsh's own variable; the quarantine ledger and patch paths follow it) | | `DSH_SAFE_AI_KEY` | AI feature key (unset = AI disabled entirely); defaults to DeepSeek | | `DSH_SAFE_AI_BASE_URL` | AI endpoint (OpenAI-compatible), default `https://api.deepseek.com` | | `DSH_SAFE_AI_MODEL` | AI model, default `deepseek-chat` | | `DSH_SAFE_AI_RECOVER=1` | enable AI fallback when regex signatures can't identify the broken plugin (results go through the same quarantine pipeline) | How upgrading works: `dsh-safe update` auto-detects the dsh package name and install method (npm / pnpm global installs), compares against the latest version and runs the upgrade for you, then automatically restores all quarantined plugins — any still incompatible under the new dsh will be auto-quarantined again on the next start. For daily use, just make `dsh-safe -u web` your start command: prints a one-line status and boots immediately when dsh is already latest (one version check), upgrades + restores first when an update is available, and only warns (still boots) if the update check itself fails. `-u` accepts update options (e.g. `-u -y web`) and wrapper flags (e.g. `-u --max-retries 0 web`). Version channels: only npm's `latest` is followed (one `npm view dist-tags --json` call fetches every channel, so boot cost does not change). Upstream often publishes a new version to `next` / `alpha` first, which produces the "latest is already current, but some channel is newer" situation — `-u` still reports "already up to date on the latest channel", **but follows it with one channel-gap line per newer channel, ascending by version** (e.g. `the next channel has 0.1.5-rc.2` then `the alpha channel has 0.1.6-alpha.1`; naming only the highest version used to hide `next` behind `alpha`), and never auto-upgrades there (dsh-safe does not decide for you to move onto an unreleased channel). To follow it explicitly: `dsh-safe update --to ` (`next` / `alpha` both work). Channel names are not hard-coded, so `beta` / `canary` added upstream would be reported too. The boot path reports it every time too: `-u`, `update` and `--check` behave alike, with no "silent once a dsh argument is added" difference (there used to be an at-most-once-a-day gate there, which read as "sometimes it tells me, sometimes it doesn't" — removed). If the daily start feels noisy, switch it all off with `DSH_SAFE_NO_UPDATE_CHECK=1`. To ask "would it upgrade, and to what?" without changing anything, use `dsh-safe update --check`. ## How It Works 1. **Failure identification**: when dsh fails to start, stderr carries five kinds of signatures (`plugin(s) failed to load: …`, `N entries did not activate` with per-row failures, `failed to apply/import loader entry ()`, outer stack frames `…#`, and `duplicate loader entry id: ` duplicates). dsh-safe extracts the broken plugin's package name and row id from them. Those five are the **dsh ≤ 0.1.5** shapes; 0.1.6 added two **structured** diagnostics that get their own parser (next paragraph). **The two structured diagnostics of dsh ≥ 0.1.6** (parsed as structure, not guessed from free text): the tolerant warning `dsh: warning: N entries did not activate` followed by ` (): ` lines, and the fatal diagnostic `dsh: startup failed: N required plugins did not activate` followed by `Failed plugins (N):` (` (required)` / ` Package: ` / indented reason) and `Plugins waiting for services (N):`. Row id, package name, the **required marker** and the reason all come straight from upstream output, which is far more reliable than the old shapes; lines consumed by these structures are excluded from the legacy rules (otherwise ` Package: x` and ` Error: …` get read as package names `Package` / `Error` — measured with 0.17.0 against 0.1.7-rc.2). **Required entries are never quarantined**: the `(required)` row ids in a fatal diagnostic (`webserver`, `connection`, `agent-loop`, …) are exactly the ones whose loss means "booted but unusable", so disabling them fixes nothing; they get the same protection as core dependencies, with no flag able to override it. **Exception — environment failures are never quarantined**: if stderr contains an errno-style environment error (`EADDRINUSE` for a taken port, `EACCES`/`EPERM` for permissions, `ECONNREFUSED`/`ENOTFOUND` for network, …), dsh-safe treats the failure as **not attributable to any plugin**: it writes no files at all and just passes the exit code through with an explanation. The reason is that an environment problem makes healthy plugins fail too — when webserver (the provider of `webServer`) cannot apply because port 3080 is held by another dsh instance, the plugins depending on it merely report `pending (waiting for service: webServer)`. Any quarantine decision made from that stderr is a misdiagnosis, and quarantine is a persistent write. Fix the environment and restart; the plugins stay enabled throughout. 2. **Match against real rows**: it scans the profile patch, `$DSH_HOME/cordis.patch.yml` (home layer) and each bundle's patch to build a "row id ↔ plugin package" mapping; only rows that actually exist are disabled, avoiding collateral damage. Official bundles (`@deepseek-ai/dsh-base`, `dsh-web-app`, …) are **not** in the profile's `node_modules` — they live in the dsh installation's own directory, which is scanned too (those rows are marked internal: used only for package-name and source resolution, never for duplicate-source detection, so dedupe behaviour is unchanged). 3. **Managed block writing**: it appends a marker-commented managed block at the end of the matching patch file (same convention as `dsh-mcp-config managed`), setting matched rows to `disabled: true`. Existing user content and comments are preserved; a fresh profile's `[]` template is correctly replaced with a block sequence. 4. **Ledger & restore**: quarantine records live in `$DSH_HOME/dsh-safe/quarantine.json`. Once a plugin upgrade fixes the issue, `dsh-safe restore --profile web --all` removes the managed block and re-mounts the plugin (hot-applied for profiles with `patchReload: live`). ### Read-only inspection (dsh ≥ 0.1.6) Since 0.1.6 upstream downgrades any non-required entry failure to a single warning line and lets startup succeed — the **quarantine path can no longer be triggered** (the only remaining fatal cases are required entries, which the rule above protects). The price is that plugins go silently missing: the feature is gone, upstream writes no report, and a GUI-launched dsh never shows that stderr line. dsh-safe therefore performs one extra **read-only inspection** on a successful boot: it parses `warning: N entries did not activate` and lists each inactive entry's row id, package name and reason, plus the repair entry point: ``` [dsh-safe] boot succeeded, but 1 plugin(s) did not activate (dsh 0.1.6+ fails only for required entries; the rest merely warn — the plugin is silently missing): badplug (@acme/broken-plugin): failed to import [dsh-safe] no file was changed for the entries above; repair: dsh-safe repair , interpret: dsh-safe explain. ``` The inspection is **read-only**: it does not touch the patch, the ledger or retries, behaves identically under `--dry-run`, and prints nothing at all when no entry is inactive. Repair with `dsh-safe repair ` (reinstall/upgrade through the `dsh plugin` pnpm channel) or by hand, then restart to verify. ### Let the AI agent in the web UI take over repairs (recommended) The wrapped boot already owns keep-alive end to end: failing plugins are auto-quarantined and retried, duplicate mounts are auto-deduped and retried. After boot, the ledger records left behind are the "fix-me list" — hand repairs to the AI agent in the web UI. Add the following snippet to a dsh agent preset (or system prompt) so the agent can discover and fix issues proactively: ```text dsh-safe is installed on this machine (a startup fuse for dsh). When the user reports a missing plugin feature, a boot warning, or misbehaving plugins: 1. Run dsh-safe list --json to inspect the quarantine ledger (which plugins are auto-disabled, why, when). The "[dsh-safe] boot succeeded, but N plugin(s) did not activate" line in the boot output is the read-only inspection of dsh 0.1.6+ (those entries were not disabled, they simply never mounted); treat it the same way in step 3. 2. If interpretation is needed, run dsh-safe explain (requires DSH_SAFE_AI_KEY). 3. Repair based on the cause: - missing/stale package → dsh-safe repair -y (reinstall latest) - known compatible version → dsh-safe repair --to -y - duplicate mount → dsh-safe repair -y (redundant source removed) - no compatible upstream release yet → keep it disabled and say so. 4. Verify with dsh-safe --profile ; still-broken plugins are auto-quarantined again and never take down the boot. ``` ### AI Capabilities (optional) Enabled by setting `DSH_SAFE_AI_KEY` (defaults to DeepSeek; OpenAI-compatible — swap providers via `DSH_SAFE_AI_BASE_URL` / `DSH_SAFE_AI_MODEL`): - **`dsh-safe explain [id] [--profile | --file ]`**: interprets the failure info dsh-safe knows — an `id` interprets that quarantine record and suggests `repair`; by default it interprets the most recent boot failure (stderr persisted to `$DSH_HOME/dsh-safe/last-failure-.log`), falling back to the ledger; `--file`/stdin read any log. Strictly read-only — never touches the patch or ledger. - **AI fallback identification** (`DSH_SAFE_AI_RECOVER=1`): when the regex signatures can't identify the broken plugin (e.g. after a dsh upgrade changes formats), the AI picks the culprit from the stderr — **its output must pass the exact same validation pipeline** (match against real patch rows, first-party protection, dry-run preview); unmatched picks are passed through as before. Only invoked on startup failure. - Privacy: home paths are redacted to `~` before sending; any AI failure degrades silently. ## Safety Boundaries - **Core dependencies are never auto-disabled**: the entries dsh itself depends on — official plugins (`@deepseek-ai/*`), rows mounted by an official bundle, the loader mechanism (`include` / `cordis:*`), and the web UI host entry `webserver` — **never take part in auto-quarantine under any circumstance**, and no flag can open them up. Three independent signals are checked (is it a reserved entry / is the row mounted by an official bundle / is the package name in the official namespace) and any one of them protects the row; the package name used for the decision also falls back from the patch row to other layers with the same id, and finally to the **error text itself**, so a profile override row that omits `name` (such as webserver's host/port override) cannot silently defeat the protection. - **Rows whose package cannot be determined are not quarantined either**: if it cannot be confirmed as an ordinary third-party plugin, it cannot be ruled out as a core dependency, so it is treated as one (fail closed). Quarantine is a persistent write — missing a quarantine beats damaging a good row. Edit the patch manually if you really need to disable one. - **`--allow-first-party` only applies to duplicate dedupe**: unmounting a duplicate source from the manifest still requires the explicit flag or interactive confirmation, and official sources are kept by default. The **quarantine path is unaffected by that flag** — no flag can disable a core dependency. - **Duplicate dedupe is guarded the same way**: official sources are kept by default, and removing an official bundle requires an explicit flag or interactive confirmation, so official rows like webserver are never unmounted as collateral. - **Environment failures are never quarantined**: an errno in stderr (`EADDRINUSE` / `EACCES` / `ECONNREFUSED`, …) means the failure cannot be attributed to a plugin; no file is written and the exit code is passed through. - **Required entries are never quarantined**: the `(required)` row ids in a dsh ≥ 0.1.6 fatal diagnostic get the same protection as core dependencies — they are the minimum for dsh to run at all, so disabling one only turns "fails to boot" into "booted but unusable"; no flag can override it. - **Startup-phase failures only**: module resolution failures / `apply` throws / timed-out service injection. Uncaught runtime exceptions are still handled by dsh's own fail-loud policy and are out of scope for boot quarantine. - **The read-only inspection never writes**: on the dsh ≥ 0.1.6 tolerant path it only parses and reports — no patch, ledger or manifest changes; `--dry-run` behaves exactly like a normal boot. - **Auditable**: every write records the reason and a timestamp; `--dry-run` previews which plugins would be disabled. - **Faithful pass-through**: when no broken plugin can be identified, the retry limit is exceeded, or for `dsh plugin` (pnpm forwarding), the exit code is passed through untouched and no files are modified. ## Known Limitations - If the patch file itself fails YAML parsing (e.g. broken by hand-editing), plugins cannot be identified and the failure is passed through. - Rows inserted via `--patch` overlay layers are not part of the mapping (only the profile patch, the home patch and bundle patches are scanned). - To capture stderr, the wrapper pipes dsh's stderr (content is still echoed to the terminal in real time); stdout/stdin pass through unaffected. - Match patterns target the dsh 0.1.x error formats (the five free-text signatures of ≤ 0.1.5 plus the two structured diagnostics of ≥ 0.1.6); a major dsh upgrade that changes them requires updating the parser. Mitigation: after update/-u upgrades dsh it runs a parser self-check — boots the new dsh with a throwaway profile and confirms failures are still recognized, warning right away on mismatch (`--no-verify` skips it). Note that on dsh ≥ 0.1.6 that self-check reports "unverified" because the deliberately broken profile boots successfully — that is the expected tolerant behaviour, not a broken parser (the quarantine path has no trigger surface there, and the inspection path has its own tests). - Windows is best-effort: update / --self / list / restore are adapted (.cmd shim parsing, shelled npm/pnpm invocations); the wrapped boot resolves the node entry embedded in dsh's .cmd/.ps1 shim on PATH and spawns `node ` directly (.exe runs as-is, unparseable shims fall back to a shelled spawn), sidestepping Node's ban on spawning .cmd files. This machinery is covered in full on `windows-latest` (see [Development](#development)), though against a fake dsh — the complete install flow with a real dsh still depends on user feedback. ## Development ```bash npm test # node:test unit tests + fake-dsh integration tests ``` The CI matrix is macos / ubuntu / **windows** × node 20/22, and all three platforms run the full `npm test`. Integration tests launch the real `bin/dsh-safe.js`, so they must first build a fake dsh — and **that build has to be platform-specific**: on POSIX it is an extension-less shebang script plus a symlink and the executable bit; Windows has no shebang mechanism and cannot execute an extension-less file, so it needs a `.cmd` shim, and PATH must be joined with `path.delimiter` rather than a hardcoded `:` (a hardcoded colon makes the whole PATH look like a single entry). A hand-built POSIX-only fixture fails silently on Windows — **when a change is Windows-only, your local run and the macos/ubuntu CI will both stay green**. For new integration tests use `installFakeDsh` / `installFakePm` from [`test-utils/fakebin.js`](./test-utils/fakebin.js). Note their spawn paths differ: dsh's shim is only **parsed** (spawning `node ` instead, never through a shell), whereas package managers are spawned **directly** — so on Windows the latter must be a genuinely executable `.cmd` plus `.js`, and a merely parseable shape is not enough. `test/fakebin.test.js` validates both fixture shapes on any platform via `platform` injection. For Windows-related logic, **trust the `windows-latest` CI result, not a local green run** — the common thread in this class of bug is that you cannot work it out locally. Release gate: see [RELEASING.md](./RELEASING.md) (in Chinese). ## License [MIT](./LICENSE)