← Back to home@Linicc

dsh-plugin-guard

Stop malformed plugin mounts from failing every DeepSeek request with REQUEST_EXTENSION — a guard for DeepSeek Harness.

Stars
1
Language
JavaScript
Created
Oct 6, 2026
Updated
Oct 6, 2026
GitHub repo

Introduction

dsh-plugin-guard

dsh-plugin license: MIT

Plugin-mount identity guard for DeepSeek Harness.

Stops a malformed plugin mount from failing every DeepSeek request with REQUEST_EXTENSION.

中文说明见 README.zh.md


The problem

@deepseek-ai/dsh-plugin-package-inventory-deepseek resolves a package identity for every active Loader row at request time. When a row's name is a path, it walks up to the nearest package.json and demands that name and version are both non-empty — otherwise it throws:

function identityFromManifest(path, allowAnonymous) {
  const manifest = JSON.parse(readFileSync(path, "utf8"));
  if (allowAnonymous && manifest.name === void 0) return void 0;   // ← only tolerates a missing name
  if (typeof manifest.name !== "string" || manifest.name.length === 0
   || typeof manifest.version !== "string" || manifest.version.length === 0)
    throw new Error(`... ${path} must declare non-empty name and version`);
  return { name: manifest.name, version: manifest.version };
}

dsh-llm-deepseek then wraps that throw:

catch (error) {
  throw new LlmError("DeepSeek request extension preparation failed", "REQUEST_EXTENSION", { cause: error });
}

Result: every official DeepSeek request fails. The UI shows only the generic wrapped message, so it looks like a model or account problem.

Why it is easy to hit

The failing manifest is usually DSH's own profile manifest, which ships with a name but no version:

// $DSH_HOME/profiles/<name>/package.json   ← generated by DSH
{ "name": "dsh-profile-desktop" }

So any local plugin mounted without its own package.json triggers it:

# $DSH_HOME/profiles/<profile>/cordis.patch.yml
- insert:
    - id: myplugin
      name: "C:/Users/<user>/.dsh/profiles/<profile>/plugins/myplugin/index.ts"

nearestManifest() walks up from plugins/myplugin/ → plugins/ → profiles/<profile>/ and lands on that version-less manifest.

It contradicts the caller's own contract

The caller already tolerates an unresolvable identity:

const identity = resolver.resolve(activeEntry);
if (identity === void 0) continue;        // contract: undefined = skip this entry

resolve() is even typed as ... | undefined. Throwing here is error-severity misclassification: best-effort diagnostic metadata should not hold a request-level veto.


What this plugin does

Three defense layers, all implemented by mirroring the upstream resolution rules exactly. Ships as a DSH bundle (dsh.bundle.patch), so it can be installed rather than copied.

#MechanismWhenEffect
①Startup self-healapply(), before any model requestScans every profile's patch layers; writes a minimal compliant package.json next to any offending plugin module
②Check-on-writeafter any profile .yml/.yaml is writtenRe-audits and self-heals immediately, appending the result to the tool output
③Three toolson demanddsh_guard_audit / dsh_guard_repair / dsh_guard_verify

Startup self-heal is profile-wide: load the plugin once and every session in that profile is covered. Measured cost: ~1 ms.


Installation

This repository is a DSH bundle: an npm package that declares dsh.bundle.patch, so a profile can list it in dsh.profile.bundles and get the plugin row inserted automatically.

As a bundle (recommended)

# from a clone
dsh plugin --profile <profile> add /path/to/dsh-plugin-guard

# or straight from git
dsh plugin --profile <profile> add github:Linicc/dsh-plugin-guard

dsh plugin forwards to pnpm inside the profile directory, then appends the package to dsh.profile.bundles because it declares dsh.bundle. The bundle's cordis.patch.yml is what inserts the plugin row:

- insert:
    - id: plugin-guard
      name: dsh-plugin-guard

Note that the row references the package by name, not by path — that is what lets Node resolve the installed code. It also means this row takes the bare package resolution branch, which is the one that does not depend on a nearest-manifest walk.

As a local module (no bundling)

If you would rather not install a package, clone anywhere and mount the file by absolute path:

# $DSH_HOME/profiles/<profile>/cordis.patch.yml
- insert:
    - id: plugin-guard
      name: "<abs path>/dsh-plugin-guard/src/index.ts"

⚠️ The plugin directory must contain a compliant package.json. Mounting a directory that has none is exactly the condition this plugin exists to prevent — see the problem. This repository ships one, but if you copy the source elsewhere, copy the manifest too.

Configuration

- id: plugin-guard
  name: dsh-plugin-guard
  config:
    autoRepair: true       # write the missing package.json (default: true)
    verbose: false         # print audit details to the console
    # profileRoot: "..."   # default: $DSH_HOME/profiles
FieldTypeDefaultMeaning
autoRepairbooleantrueSelf-heal at startup and on patch writes
verbosebooleanfalsePrint audit details
profileRootstring$DSH_HOME/profilesRoot scanned for profiles

Repository layout

dsh-plugin-guard/
├─ package.json        declares dsh.bundle.patch; main -> lib/index.js
├─ cordis.patch.yml    the layer a profile applies when it lists this bundle
├─ lib/index.js        compiled entry (committed, so consumers need no build step)
├─ src/index.ts        source
├─ scripts/build.mjs   regenerates lib/ from src/ using Node's built-in type stripping
├─ README.md / README.zh.md
└─ LICENSE

Rebuild after editing the source:

npm run build      # node scripts/build.mjs — no dependencies required

Tools

dsh_guard_audit

Audits every profile's plugin mount rows and lists those that would throw.

✅ plugin-guard audit passed
  profiles 2 · rows 8 · all identities resolvable
    · desktop/plugin-guard → dsh-plugin-guard@0.1.0

On failure it names the profile, the row id, the module directory, and the exact problem.

dsh_guard_repair

Writes a minimal package.json for non-compliant plugin directories. Idempotent, never overwrites. Supports dryRun.

dsh_guard_verify

Pre-install check: given a plugin entry file or directory, tells you whether it will pass the official resolution before you mount it.


Implementation notes

The resolution rules are mirrored one-to-one so that the guard can never disagree with upstream:

UpstreamRuleHere
barePackageName(specifier)starts with . / contains : / isAbsolute() → treat as a pathisPathLike()
nearestManifest(dir)walk up from the module directory to the first package.jsonnearestManifest()
identityFromManifest(path, allowAnonymous)name and version must both be non-emptyidentityFromManifest()

The guard is deliberately more conservative than upstream: upstream only inspects active rows, the guard inspects all rows. The cost is occasionally reporting a row that is disabled today but would fail if enabled — which is the desired behaviour.

Safety boundaries

  • Only writes when the plugin's own directory has no package.json at all; never modifies another package's manifest.
  • When the offending manifest belongs to an ancestor (e.g. the profile root), the finding is marked repairable: false and left for a human — the guard will not edit your profile manifest.
  • Every write and every audit is wrapped in try/catch: the guard must never become the failure it guards against.
  • Writes are announced on the console; nothing happens silently.

Model Experience

Request context and condition

What the model sees

Three tool schemas are contributed to the system prompt: dsh_guard_audit, dsh_guard_repair, and dsh_guard_verify, each with a Chinese description explaining when to call it. The schemas are static; no conversation content is read.

Tool descriptions (verbatim, abbreviated)
dsh_guard_audit   — 审计 DSH 所有 profile 的插件装载行,复刻官方「包身份解析」逻辑,找出会导致 REQUEST_EXTENSION (整轮 DeepSeek 请求失败)的行。任何新增/修改插件之后都应跑一次。
dsh_guard_repair  — 为不合规的插件目录补一个最小 package.json(name + version),消除 REQUEST_EXTENSION 隐患。幂等、永不覆盖已有文件。补完后用 dsh_guard_audit 复验。
dsh_guard_verify  — 安装一个本地插件**之前**的预检:给定 .ts/.js 文件或目录,判断它作为 patch insert 行时能否通过官方包身份解析。

Token effect

Fixed and small: three tool schemas in the tool catalogue. No result is injected unless a tool is called or a profile patch file is written.

KV Cache effect

Prefix-stable, with one exception. The tool schemas are static, so the prompt prefix is unchanged across requests. When a .tex-unrelated profile patch file is written, tools/post-execute appends a short audit note to that tool result — this changes only the tail of the conversation, not the cached prefix. No other request-context entry is added.


Known Limitations and Deferred Work

  • Upstream defect remains — this plugin is user-side prevention. It does not fix identityFromManifest() in @deepseek-ai/dsh-plugin-package-inventory-deepseek, and any other trigger of the same failure path (for example a stale fallback symlink, see upstream discussion #5439) will still take requests down. Reported upstream: discussion #4950.
  • YAML parsing is a tolerant subset — only - id: / name: pairs are extracted. Unusual patch shapes (anchors, merge keys, deeply nested inserts) may not be seen. The guard fails open (reports nothing) rather than failing the boot.
  • JSON-parsed manifest only — a syntactically broken package.json is reported, not repaired.
  • No watcher — only write/edit tool activity and host startup trigger an audit. A patch file edited by an external editor is picked up at the next DSH start.

Verification

The exact failure condition can be reconstructed in a sandbox and all four paths exercised (audit / verify / repair / startup self-heal):

Sandbox:
  profiles/desktop/package.json   → { "name": "dsh-profile-desktop" }   ← name, no version
  cordis.patch.yml                → insert row pointing at a directory with no package.json
  plugins/myplugin/index.ts       → entry file only

autoRepair: false
  dsh_guard_audit  → ok=false, problems=1          ✅ detects
  dsh_guard_verify → ok=false + remediation advice ✅ pre-checks
  dsh_guard_repair → creates package.json          ✅ repairs
  audit again      → ok=true,  problems=0          ✅ re-verifies

autoRepair: true (default)
  apply() creates the manifest in ~1 ms            ✅ defuses before it fires

Real profile
  audit → profiles 2 · rows 8 · problems 0

License

MIT — see LICENSE.