ctx.web has no provider priority and no last-wins rule. A configured web.fetchProvider
(or $DSH_WEB_FETCH_PROVIDER) selects one; without a pin, exactly one usable provider is
required and two throw WEB_PROVIDER_AMBIGUOUS at the first call
(packages/web/web/src/index.ts:189). HttpFetchProvider.available() returns a hardcoded
true, so composing web-fetch-http beside this package without a pin breaks web_fetch
outright.
apply() therefore checks the seam at mount and fails loud with the patch you need. It
cannot catch a fetch provider composed after this plugin; the shipped bundles compose the web
seam in the base layer and a plugin bundle’s rows come after it, so the order that matters in
practice is covered.
Two more facts about the shipped @deepseek-ai/dsh-base bundle that decide what you have to
write:
tool-web ships fetch: false, so web_fetch is
not even registered until you turn it on;web.searchProvider: deepseek-official, so this package’s search provider is never
auto-selected — which is why it reports itself unusable unless you configure a delegate, and
why mounting this plugin never breaks a profile’s existing search route.dsh plugin --profile <name> add @deepseek-ai/dsh-headless@0.1.0-rc.6
dsh plugin --profile <name> add dsh-netguard
dsh --profile <name> --dump-config # the dsh-netguard row should appear
@deepseek-ai/dsh-headless is in that list because a profile carrying only dsh-base has no
agent loop. The base bundle inserts the service rows — model adapters, tools, persistence, the
web seam — and the headless bundle inserts the runner that creates an agent, submits one task,
and therefore makes the tool calls this package guards. Any other mode bundle does as well;
headless is the one the examples here use because it needs no terminal and no browser.
Pin it explicitly: its npm latest tag still points at 0.0.1-rc.1, so an unpinned install
silently resolves to a much older harness.
Which harness versions this package accepts: the @deepseek-ai/dsh-* peer ranges are
^0.1.0-rc.6 || ~0.1.1-rc.0. They are ranges rather than exact pins because an exact pin makes
npm install dsh-netguard fail outright once upstream publishes a newer release: npm refuses the
tree when a transitively resolved harness package demands a version the pin excludes.
Two branches, because one cannot do it. node-semver admits a prerelease only when some comparator
in the range carries a prerelease and the identical major.minor.patch: ^0.1.0-rc.6 matches
0.1.0-rc.8 and 0.1.1, and does not match 0.1.1-rc.2, the release the next dist-tag points
at. Widening the upper bound does not help — >=0.1.0-rc.6 <0.2.0-0 excludes it too. Only a branch
naming the tuple admits it, which ~0.1.1-rc.0 (>=0.1.1-rc.0 <0.1.2-0) does for the whole
0.1.1 prerelease line.
That makes the range a maintenance commitment rather than a fix: every future prerelease line
needs its own branch. 0.1.2-alpha.2 … alpha.5 are published and are deliberately not
admitted — nothing here has been run against them, and a branch is added together with the CI leg
that tests its line.
@deepseek-ai/cordis is ^4.0.1, the range upstream declares for itself. It was pinned to exactly
4.0.1 while that was the only 4.x release; 4.0.2 is published now and both dsh@0.1.0-rc.8 and
dsh@0.1.1-rc.2 install it. A peer range narrower than upstream’s own cannot keep the harness on
the older cordis — it can only make npm refuse the install, or leave two copies of the object
model in one tree, which is the outcome the pin existed to prevent.
Which versions are tested: CI runs the end-to-end suite against 0.1.0-rc.6, 0.1.0-rc.7,
0.1.0-rc.8 and 0.1.1-rc.2, the four the workflow matrix names. That list is written out rather
than resolved from the registry when the job runs — a dist-tag read on the day of the build is a
supply-chain input, and this is a security control — so a release published later is admitted by
the peer ranges but untested here until someone adds it. tests/unit/compat.spec.ts fails if the
matrix names a version the ranges exclude, or if the sentence above stops naming the matrix.
Install from the registry or a packed tarball, not from a git spec.
dsh plugin add github:CharlotteN7/dsh-netguard resolves and writes the dependency, but lib/
is a build output that git does not carry and no prepare script rebuilds it, so the row mounts
and then fails to load. To install from a checkout, build first and add the tarball:
git clone https://github.com/CharlotteN7/dsh-netguard && cd dsh-netguard
pnpm install && pnpm run build && pnpm pack # prints the tarball it wrote
dsh plugin --profile <name> add ./dsh-netguard-0.3.0.tgz
Then write the composition into the profile’s cordis.patch.yml. A patch REPLACES a row’s
whole config, so every key the row needs is restated:
# $DSH_HOME/profiles/<name>/cordis.patch.yml
- id: web
config:
fetchProvider: dsh-netguard
searchProvider: deepseek-official # whatever your profile already uses
- id: tool-web
config:
fetch: true # the base bundle ships this off
searchTimeoutMs: 60000
# Only if your composition mounts the shipped provider; the base bundle does not.
# There is no `remove` patch operation — a `- remove: [...]` entry is skipped
# with a warning and leaves the provider mounted.
- id: web-fetch-http
disabled: true
- id: dsh-netguard
config:
mode: audit
allow: []
deny: []
spoolPath: /var/log/dsh/netguard.ocsf.jsonl
If you get this wrong, the mount fails with a message naming the conflicting provider and
quoting the patch above, character for character — a unit test compares the two. What the check
covers: a pin naming another provider, an unpinned composition that already has a usable one, a
registry it cannot read, and web.searchProvider: dsh-netguard with no search.delegate
configured, which would otherwise mount cleanly and then fail every search. What it cannot
cover is a fetch provider composed after this plugin: that one surfaces as
WEB_PROVIDER_AMBIGUOUS at the first web_fetch.