Skip to content

feat: dsh plugin scanning, enable/disable, and a real MCP writer - #118

Merged
RealZST merged 11 commits into
mainfrom
feat/dsh-plugins-mcp-writer
Aug 18, 2026
Merged

feat: dsh plugin scanning, enable/disable, and a real MCP writer#118
RealZST merged 11 commits into
mainfrom
feat/dsh-plugins-mcp-writer

Conversation

@RealZST

@RealZST RealZST commented Aug 18, 2026

Copy link
Copy Markdown
Owner

Follow-up to #116 (dsh P0). Makes MCP install actually work for dsh, adds plugin scanning and enable/disable, and protects the parts dsh owns.

Plugins

dsh models a plugin as a row in a composed patch layer naming an npm package β€” not a directory. Rows come from each mounted bundle's patch, then the profile's, then the home layer, and only the booted profile's chain participates. Scanning reads that composition, so HarnessKit reports exactly what dsh's own plugin list reports: verified against dsh --dump-config β€” 132 config rows = 129 plugins + 3 MCP, sets identical, zero name mismatches.

Toggling writes an id-targeted row into a managed block appended to $DSH_HOME/cordis.patch.yml, the layer dsh documents as the user's own. Bundle files are never touched (an upgrade would overwrite them anyway). Toggling back to the base state removes the entry; emptying the block removes the markers. Everything outside the markers is preserved byte-for-byte β€” a serde round-trip would destroy the !!js tags, comments, anchors and flow style real files carry.

MCP

Install writes a real dsh-mcp-client row instead of leaving a placeholder. serverName must match /^[A-Za-z0-9_-]{1,32}$/, so a name like microsoft/markitdown is sanitized and the original stored in _hk_name β€” the same round-trip Codex already uses, which keeps the server grouped with the other agents' copies instead of forming a second row. Remove and toggle normalize the same way, so remove("My Server") can't silently no-op against the My-Server row it wrote.

Deletion

delete_extension's fallback deleted PluginEntry::path. For dsh that is the npm package inside a shared profiles/node_modules farm, while both the profile's dependencies and its dsh.profile.bundles keep naming it β€” dsh then fails to boot, sibling rows instantiating the same package break with it, and the next scan re-reports the row anyway. Destructive and ineffective.

Adapters now answer plugin_removal():

Files the default β€” unchanged for every directory-based agent
Command delegate to the agent's own uninstaller (dsh plugin remove runs pnpm and reconciles bundles in one step)
Shipped refuse β€” in-box bundles are not dependencies, so dsh's own reconcile never removes them either

Deleting also reported success when it failed: removal is optimistic (rows leave the list and the toast fires five seconds before the request) and the rejection was dropped as an unhandled rejection from a timer callback. Pre-existing, but this refusal is the first routine way to hit it.

Extensions list

dsh contributes ~130 plugin rows against ~20 from every other agent combined, because its whole product is plugins and its baseline ships as data rather than compiled in. Its in-box bundles now collapse into one source option, and a "Hide built-in" toggle appears only where something is actually hideable. Both read capabilities.vendor_baseline_packs, so no UI code names an agent β€” the eleven others never see either control.

Shown by default: dsh's own list shows everything, and a bundle-disabled row the user may want to re-enable shouldn't vanish.

Audit

One rule, dsh-js-env-no-fallback: a !!js config expression reading process.env.X with no ??/|| fallback fails the whole boot at mount time when the variable is unset. !!js is evaluated through new Function with no require in scope, so it can only read the environment β€” which is what makes a bare read a boot risk rather than a style nit.

Verified on a real dsh install

All four write paths, against dsh's own --dump-config and its live pluginInventory/list:

  • disable β€” turning off agent-loop left dsh with no agent factory and visibly broke its UI (it is the only package registering one)
  • enable β€” restoring it fixed that; enabling hmr correctly overrode the dsh-web-app bundle's disable
  • MCP install β€” row written with _hk_name, read back under the original name
  • MCP remove β€” hot-unloaded live (161 β†’ 160 entries), the two hand-written rows using the same package untouched

Bundle patches stayed byte-identical throughout; the tree was restored to a sha256-identical state.


πŸ€– Generated with Claude Code

RealZST and others added 11 commits August 17, 2026 21:44
dsh's own vocabulary is the composed cordis patch ROW: Settings β†’ Plugins
lists one entry per row (`timer`, `hmr`, `llm`, …) and never lists a bundle,
because a bundle is a patch LAYER that inserts rows. `read_plugins` mirrors
that with two sources per profile: rows defined by each mounted bundle's own
patch file (resolved via `dsh.bundle.patch`, from the symlink farm for in-box
bundles) and rows defined by the user's own home/profile patch files.

Composition follows dsh's: bundle patches in `bundles` order, then the profile
patch, then the home patch. The earliest layer defining a row id owns the
entry (upstream, a later restatement can only override), while `enabled` folds
`disabled` across the WHOLE chain β€” which is why `hmr`, defined enabled by
dsh-base and disabled by dsh-web-app, reads as disabled exactly like dsh shows
it. Identity carries profile + bundle + row id, since two profiles compose
different chains and can disagree about one row.

Packages that are profile dependencies but which no layer mounts are NOT
listed: dsh warns once at install time and then never loads or displays them.
Same principle applies to skills β€” dsh drops a skill whose frontmatter uses a
camelCase invocation key, so `scan_skill_dir` emits no extension for it under
dsh. In a shared skills root that removes only dsh from the skill's agent
list; a dsh-only skill disappears from HK entirely. The detector is a small
`pub(crate)` predicate beside `CAMELCASE_INVOCATION_KEYS`, so the rejected-key
vocabulary keeps one home while the audit rule's own scan is untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Toggling a dsh plugin appends an `{id, disabled}` override inside HarnessKit's
comment-marker block at the end of the HOME `cordis.patch.yml` β€” the precedent
is dsh's own web-app bundle, which disables base rows exactly that way. The
home layer is applied after every profile layer, so the toggle is
machine-global, like the P0 MCP toggle, and hot-reload picks it up live.

What is new over the P0 helpers is the base state. P0 folded row ids in the
home file's text alone; a plugin row can live in a bundle patch or a profile
patch, and its base state is the fold across the WHOLE layer chain, not the
layer that defines it. `hmr` is the proof: dsh-base defines it enabled and
dsh-web-app disables it two layers later, so folding only the defining layer
would read it as enabled, make "enable" look like a no-op against base, drop
the override and leave the row disabled. `PluginEntry` therefore carries
`base_layers` β€” that profile's ordered layers below the home patch, produced
by the scan β€” and `deployer::set_dsh_plugin_enabled` folds exactly those plus
the home patch while writing only the home file. Bundle patch files stay
read-only inputs. The `layer == home_patch` identity guard is load-bearing: a
home-defined row must fold the block-stripped user text, not HK's own block.

Bundle-provided rows are toggleable like any other row. Anonymous rows (no
`id:`) are not, and get a backend `Validation` error advising an `id:` β€” UI
graying alone is not a guard. `toggle_plugin` gains an explicit dsh branch
BEFORE the generic manifest-rename fallback, which would otherwise hunt for a
`plugin.json` in node_modules and rename it, corrupting a pnpm-managed tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finishes wiring dsh MCP install now that the insert writer works. dsh's
remote rows are `{transport, url, headers}` YAML, which no existing
`RemoteMcpSchema` variant describes β€” reusing `Toml` would break the
documented "Codex is the only Toml agent" invariant β€” so add a `DshTransport`
variant and update BOTH sites that switch on the schema: the SSE capability
derivation in `AgentCapabilities::from_adapter` (http true, sse false) and
`validate_remote_mcp_target`, which now refuses SSE for dsh with a clear
message before anything is written. Missing either would let SSE servers look
installable and reach the writer. The two now-stale `Toml`-is-the-only-HTTP
comments in adapter/mod.rs are corrected alongside.

`remote_mcp_schema()` is flipped last on purpose: advertising it before the
writer existed would have offered an install path that always errored.

Kit installs route through the same `deploy_mcp_server`, so dsh Kit MCP
install starts working implicitly β€” which means the `kits/install_plan.rs`
conflict-detection arm can no longer answer a hardcoded `false`. It now looks
the serverName up in the home patch text, normalizing the query the same way
the writer sanitizes what it stores, so a conflict is reported for the row
that actually exists on disk.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dsh evaluates `!!js` config expressions at plugin MOUNT time. An expression
that reads `process.env.X` with no `??`/`||` fallback yields `undefined` when
the variable is unset, which fails mcp-client config validation and kills the
WHOLE dsh boot β€” real-machine verified on rc.6. The rule reports it as a
warning with real file:line.

The patch text rides a dedicated `AuditInput::raw_config` field rather than
`content`: the cordis patch file is shared by every dsh MCP row, so putting it
in `content` would let the content-scanning rules report a neighbouring
server's token on this row. `service::run_audit` attaches the raw text to the
first dsh MCP row only; the reported file:line may therefore point into
another server's block, which is accepted.

A rule ships with the surface that displays it, so this also registers
`dsh-js-env-no-fallback` in the frontend `AUDIT_RULES` registry and adds its
label/description to all three locales (en, zh, zh-TW), with an i18n parity
test so a rule can never again reach the Audit page untranslated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four block-corruption tests were byte-identical apart from the malformed
input, so they become one table-driven test whose case labels carry the
reason each input is corruption. `toggle_and_remove_work_through_the_
original_name` duplicated the path `remove_and_toggle_by_original_name_
hit_the_sanitized_row` already walks; its one extra assertion (toggle
back on) moves into the survivor, which keeps asserting on raw bytes so a
reader that also normalized could not hide a writer that did not. The
reader round-trip it covered is `install_records_the_original_name_and_
the_reader_round_trips_it`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dsh plugin is a ROW in a composed patch layer naming an npm package,
reached through two manifest records β€” the profile's `dependencies` and
its `dsh.profile.bundles` layer list. `delete_extension`'s fallback
deleted `PluginEntry::path`, which for dsh is that package inside a
SHARED `profiles/node_modules` farm: both records kept naming it, dsh
failed to boot (a configured-but-absent package is a hard mount error),
sibling rows instantiating the same package broke with it, and the next
scan re-reported the row from the patch text anyway. Destructive and
ineffective. Reachable only since this branch made dsh report plugins.

Adapters now answer `plugin_removal()` β€” `Files` (the default, unchanged
for every directory-based agent), `Command` (delegate to the agent's own
uninstaller: `dsh plugin remove` runs pnpm AND reconciles `bundles` in
one step), or `Shipped`. In-box bundles are not dependencies, so dsh's
own reconcile never touches them either; refusing them mirrors the
vendor's constraint rather than inventing one. A missing binary reports
the exact command instead of falling back to deleting files.

`PluginEntry::pack` carries the bundle that provided a row, since the
git-URL derivation only fires for git checkouts and would leave the whole
vendor baseline unattributed. `AgentCapabilities::vendor_baseline_packs`
publishes the `Shipped` set so the detail panel greys out delete on
exactly the rows the backend refuses.

Deleting also reported success when it failed: the optimistic removal
drops rows and toasts five seconds BEFORE the request, and the rejection
was dropped as an unhandled rejection from a timer callback. Failures now
restore the rows and say why β€” pre-existing, but this refusal is the
first routine way to hit it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dsh contributes ~130 plugin rows against ~20 from every other agent
combined, because its whole product is plugins and its baseline ships as
data rather than compiled in. Flat, that baseline IS the plugin list.

Two additions, both driven by `capabilities.vendor_baseline_packs` so no
UI code names an agent. An agent's in-box bundles collapse into one
source option (`DeepSeek built-in (129)`) β€” three bundles is how dsh
ships, not three sources the user chose between. And a "Hide built-in"
toggle sits by the result count, rendered only where something is
actually hideable, so the other eleven agents never see it.

Shown by default: VS Code hides its built-ins and Obsidian gives core
plugins their own tab, but dsh's own list shows everything, and hiding a
row the user could re-enable would be worse than the noise. The label
names the mode and never changes β€” flipping it to the next action would
contradict the tint, since a lit control reads as "on" while the verb
claims the opposite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The agent key stays `dsh` (config paths, env vars, adapter name); only
the display name changes, which flows to every surface that renders
`agentDisplayName` β€” filter dropdowns, install targets, onboarding. The
i18n prose already said "DeepSeek Harness".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capability matrix still showed "β€”" for its Plugins column. Hooks stay
"β€”": dsh has no hook format of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@RealZST
RealZST merged commit 85a6ff1 into main Aug 18, 2026
3 checks passed
@RealZST
RealZST deleted the feat/dsh-plugins-mcp-writer branch August 18, 2026 02:41
RealZST added a commit that referenced this pull request Aug 18, 2026
The hero GIF was recorded in June, before Kiro, Oh My Pi and DeepSeek
Harness existed and before Windsurf became Devin Desktop. It showed nine
agents against a README that claims twelve. Replace it with a recording
of all twelve under current names, at 1376x480 (down from 3840x732),
which also drops the file from 13.26 MB to 9.45 MB. Both READMEs
reference the same path, so neither needs a markup change.

Also mark Plugins as supported for DeepSeek Harness in README.zh-CN.md.
#118 shipped dsh plugin scanning and enable/disable and updated the
English table, but the Chinese one still said the type was unsupported β€”
which contradicted the very feature that release led with. Every one of
the twelve rows now matches between the two files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant