dsh-plugin-browserskill
Tencent/BrowserSkillBrowserSkill 的 DeepSeek Harness 浏览器自动化桥接插件,通过原生浏览器工具控制可见的 Chrome 和 Edge Agent Window,支持可访问性与 VOM 页面观察、截图、隔离的多会话控制和 Web UI 实时观察浮层。
ctx.web 的 fake-ip 感知抓取后端,接管官方 http provider id:当某主机名的每一条 DNS 应答都是代理 fake-ip 段(默认 198.18.0.0/15)内的占位地址时予以接受,从而在 mihomo/Clash TUN 模式下无需关闭 fake-ip、也无需设置代理环境变量即可使用 web_fetch;IP 字面量与私有、回环、保留地址仍被拒绝。
English | 中文
A drop-in replacement for @deepseek-ai/dsh-web-fetch-http that keeps the
web_fetch tool working when the machine's DNS runs in fake-ip mode behind
a transparent-proxy (TUN) setup — with fake-ip left enabled and no
http_proxy / https_proxy / all_proxy environment variables.
dsh-web-fetch-http resolves every hostname itself and refuses the request
unless every answer is a public unicast address. Under fake-ip mode, mihomo and
Clash answer every hostname with a placeholder drawn from
dns.fake-ip-range (198.18.0.1/16 by default). ipaddr.js classifies
198.18.0.0/15 as reserved — the RFC 2544 benchmarking block — so the stock
provider rejects every hostname:
Error: URL hostname "example.com" resolves to a non-public IP address
curl and other tools are unaffected because they never make that check: they
send the packet to the placeholder address, the TUN adapter intercepts it, and
the proxy resolves the real origin. The stock provider's proxy escape hatch
does not help either, because proxyRouteFor() reads only http_proxy /
https_proxy / all_proxy, and a TUN setup sets none of them — it does not
need to.
This plugin reuses the stock provider's entire transport and replaces only the destination policy.
The package is published to npm. A bare install resolves latest:
dsh plugin --profile web add dsh-web-fetch-fakeip
This is the right command for every DSH line from 0.1.2-rc.1 through 0.2.x.
0.1.1 — the previous latest — is skipped by the DSH 0.2.0-rc.2
compatibility gate, so if you are on that DSH version and installed before this
release, run an update:
dsh plugin --profile web update dsh-web-fetch-fakeip
dsh plugin forwards to pnpm inside the profile, so any npm specifier works
the same way — an exact version (dsh-web-fetch-fakeip@0.2.0), a dist-tag
(...@next), a git URL, or a local path.
Dist-tags: latest tracks the current DSH line. A release whose version has a
prerelease suffix (-rc.1, -alpha.2) publishes under next instead, so
latest only ever moves to a version that was cut deliberately. The plugin's
minor version names the DSH minor line it supports — 0.2.x supports DSH
0.2.* — see the versioning policy.
From a local checkout instead:
# From the directory holding this checkout. The relative path is anchored to
# your invoking directory, not to the profile.
dsh plugin --profile web add /path/to/dsh-web-fetch-fakeip
Because this package declares dsh.bundle in its package.json, dsh plugin
adds it to dsh.profile.bundles automatically and its
cordis.patch.yml is applied as a layer: the stock
web-fetch-http row is disabled and this provider is inserted in its place.
Restart the profile (or let the profile's patchReload: live watcher pick it
up) and the web_fetch tool works again.
If you would rather not install a package, copy the directory into your profile and point a patch row at the file:
cp -r dsh-web-fetch-fakeip "$DSH_HOME/profiles/web/plugins/"
# $DSH_HOME/profiles/web/cordis.patch.yml
- id: web-fetch-http
disabled: true
- insert:
- id: web-fetch-fakeip
name: './plugins/dsh-web-fetch-fakeip/src/index.js'
A patch replaces the targeted row's whole config, and a patch that matches
nothing warns and is skipped — so the disabled: true row above is safe even
if the stock row is absent.
# Should print the composed rows: web-fetch-http disabled, web-fetch-fakeip inserted.
dsh --profile web --dump-config | grep -A3 fakeip
Then ask the agent to fetch a page, or run the live suite:
npm run test:live
A restart is required after installing or removing a bundle. The profile's
patchReload: live watcher only re-reads cordis.patch.yml; the
dsh.profile.bundles list is read once at boot. Adding the bundle registers it
in that list immediately (so --dump-config shows it), but the running host
keeps the layer stack it booted with until it restarts.
dsh plugin --profile web remove dsh-web-fetch-fakeip
dsh plugin reconciles dsh.profile.bundles after pnpm finishes, so the bundle
layer leaves the stack along with the dependency. Restart the profile
afterwards. The stock web-fetch-http row returns automatically — the disable
came from this bundle's patch, so removing the bundle removes the disable too.
dsh plugin --profile web update dsh-web-fetch-fakeip
A github: spec without a ref tracks the repository's default branch, and pnpm
resolves it at install/update time — so updates are explicit, never silent.
Since DSH 0.2.0-rc.2 the harness refuses to load a bundle whose
peerDependencies exclude the running DSH version, and says so once at
startup:
dsh: skipping profile bundle "dsh-web-fetch-fakeip": Error: Plugin
dsh-web-fetch-fakeip@0.1.1 is incompatible with dsh 0.2.0-rc.2: ...
The web_fetch tool then behaves exactly as it did before this plugin existed
— every hostname fails with resolves to a non-public IP address under
fake-ip DNS. It is not a crash: the bundle is skipped and the stock provider
takes over.
Fix it by updating, which is the intended remedy:
dsh plugin --profile web update dsh-web-fetch-fakeip
The peer range is bounded (>=0.1.2-rc.1 <0.3.0-0) rather than a list of
published prereleases, so a new 0.2.x or 0.2.0 final no longer trips the
gate. A 0.3.0 line deliberately does: that is a real compatibility claim
nobody has tested yet, and the harness is right to ask.
dsh plugin allow-version can grant an exact-version exemption instead. It is
the escape hatch for when you know the seam did not change — not the fix. For
this plugin the honest answer is to update, because the declared range is what
was wrong, not the code.
Nothing needs configuring — every field defaults to the stock provider's value.
This is what the bundled cordis.patch.yml does:
- id: web-fetch-http
disabled: true
- insert:
- id: web-fetch-fakeip
name: 'dsh-web-fetch-fakeip'
See examples/mihomo-default.patch.yml for
the same row written out explicitly.
dns.fake-ip-rangeMatch fakeIpRanges to whatever the proxy actually writes, or fake-ip
hostnames keep failing:
- id: web-fetch-http
disabled: true
- insert:
- id: web-fetch-fakeip
name: 'dsh-web-fetch-fakeip'
config:
fakeIpRanges:
- 10.18.0.0/16
- 198.18.0.0/15
More than one block is allowed; an answer set is accepted when every address
falls in some configured block. See
examples/custom-range.patch.yml for this
plus tightened transport limits.
| Proxy | Configuration key |
|---|---|
| mihomo / Clash / Clash Verge | dns.fake-ip-range |
| sing-box | dns.fakeip.range |
For a GUI client the effective value is the generated runtime config, not the
subscription profile — look for the key above in the client's own configuration
directory and confirm enhanced-mode is fake-ip:
dns:
enhanced-mode: fake-ip
fake-ip-range: 198.18.0.1/16
scripts/diagnose.mjs reads back whatever DNS actually answers, so you do not
have to find the file to know what to configure.
Every field defaults to the stock provider's value, so mounting this plugin with
no config: changes exactly one thing: how a destination is judged.
| Field | Default | Meaning |
|---|---|---|
fakeIpRanges | ['198.18.0.0/15'] | Placeholder blocks accepted as a fake-ip answer set |
maxResponseBytes | 5000000 | Maximum response body size in bytes |
maxBodyChars | 100000 | Maximum decoded body length in characters |
timeoutMs | 30000 | Fetch timeout — a resource backstop, not the tool budget |
maxRedirects | 5 | Maximum same-origin redirect hops (0 follows none) |
userAgent | deepseek-harness/0.0.1 (+https://github.com/deepseek-ai) | User-Agent header sent on every request |
An invalid value fails loudly at plugin construction rather than building a provider with nonsensical caps, matching the stock provider. The URL length limit is fixed at 2,048 characters.
198.18.0.0/15 rather than the 198.18.0.1/16 defaultmihomo's documented default is 198.18.0.1/16, which is a subset of
198.18.0.0/15. The wider block is the RFC 2544 benchmarking range as a whole,
so the default covers both spellings and the neighbouring block that some
configurations use. Narrowing it to /16 is safe if you prefer to be strict.
| Destination | Behaviour |
|---|---|
| Hostname whose answers are all fake-ip placeholders | Accepted, pinned as-is; the connection reaches the TUN adapter and the proxy resolves the real origin |
| Hostname resolving to public unicast | Accepted, exactly as the stock provider |
| Hostname resolving to private / loopback / reserved | Refused (WEB_BLOCKED_URL), exactly as the stock provider |
| Hostname with a mixed answer set (placeholder + any real address) | Refused — never partly accepted |
IP literal in the placeholder block (http://198.18.0.100/) | Refused — a literal states a destination and never takes the fake-ip path |
IP literal that is private/loopback/link-local (127.0.0.1, 192.168.1.1, 169.254.169.254, 10.0.0.1, [::1]) | Refused |
IP literal that is public (1.1.1.1) | Accepted through the normal validated path |
Everything else is inherited unchanged from the stock provider: same-origin-only
redirects, byte and character caps, Content-Type classification, charset
decoding from the Content-Type header, rejection of binary types and of URLs
carrying credentials, and the same WebError codes (WEB_INVALID_URL,
WEB_BLOCKED_URL, WEB_FETCH_TOO_LARGE, WEB_FETCH_TIMEOUT,
WEB_REDIRECT_BLOCKED, WEB_UNSUPPORTED_CONTENT_TYPE, WEB_ABORTED,
WEB_PROVIDER_ERROR).
Run the bundled diagnostic first — it separates the three cases that look alike from the tool's error message. When installed as a bundle, resolve its path through the package so it works wherever pnpm placed it:
# From the profile directory (the profile that has the bundle installed).
node --input-type=module -e "import('dsh-web-fetch-fakeip/scripts/diagnose.mjs')"
Or run the file directly — from a source checkout, or via the installed path:
node scripts/diagnose.mjs
node scripts/diagnose.mjs example.com api.github.com
It prints each hostname's answers with their ipaddr.js range and whether the
configured blocks cover them, then gives a verdict:
fakeIpRanges to the reported block.To confirm the composed tree actually contains the replacement row:
dsh --profile web --dump-config | grep -B1 -A4 'fakeip\|web-fetch-http'
HttpFetchProvider accepts a resolver as its second constructor argument —
that is the whole seam. This plugin passes a resolver that understands fake-ip
and lets the stock class do everything else:
import { HttpFetchProvider, LOCAL_FETCH_PROVIDER_ID } from '@deepseek-ai/dsh-web-fetch-http'
const inner = new HttpFetchProvider(limits, createFakeIpResolver(ranges))
ctx.web.registerFetchProvider({
id: LOCAL_FETCH_PROVIDER_ID,
available: () => true,
fetch: (request, signal) => inner.fetch(request, signal),
})
The resolver's decision, per answer set:
WEB_PROVIDER_ERROR).Step 3 is deliberately all-or-nothing. A placeholder carries no destination of its own — only the local TUN adapter can route it — so accepting one does not open a path to a private service. A mixed set is refused because otherwise a DNS answer could widen the policy by smuggling a private address alongside a placeholder.
| File | Role |
|---|---|
src/index.js | Plugin entry: registers the provider, re-exports the API |
src/config.js | Config schema, defaults mirroring the stock provider, limit validation |
src/resolver.js | Destination policy: classification, range matching, the resolver decision |
cordis.patch.yml | Bundle patch: disables the stock row, inserts this one |
scripts/diagnose.mjs | Reports what DNS answers and whether the config covers it |
scripts/check-package.mjs | Fails when the published tarball is missing a required file or ships a forbidden one |
scripts/release-control.mjs | Release gates: tag/version validation, npm idempotency check, CHANGELOG notes, GitHub Release sync |
test/resolver.test.js | Offline unit suite (no network) |
test/compat.test.js | Asserts the declared DSH peer range against the harness's compatibility gate, and that apply() registers into a real ctx.web |
test/release-control.test.js | Offline tests for the release gates |
test/transport.live.js | Opt-in live suite (real transport, plus apply() driven through a live ctx.web) |
Both providers register the same fetch-provider id (http), and the web seam
rejects a duplicate with WEB_DUPLICATE_PROVIDER. Disabling the row does not
remove the package — this plugin imports HttpFetchProvider from it. Keeping
the same id means ctx.web.fetch() selects this provider under the existing
fetchProvider: http configuration, with no change to the web service row.
Requires Node 22+ (developed on Node 24).
npm test # offline unit suite — no network, no DNS
npm run test:live # opt-in live suite — needs working DNS and egress
npm run check # syntax check plus the offline suite
npm run pack:check # assert the published tarball's contents
npm run verify # check + pack:check — what the release workflow gates on
The offline suite injects the resolver, so it asserts the destination policy without touching the network. The live suite asserts both halves at once: a real hostname fetches even though it resolves to a placeholder, and every private destination is still refused. Its fake-ip assertions skip themselves on a host without fake-ip DNS, so the suite stays meaningful anywhere.
test/compat.test.js covers the failure mode the other suites cannot see. It
re-derives the harness's compatibility verdict from package.json — the same
decision evaluatePluginCompatibility makes at boot — so a peer range that
would get the whole bundle skipped fails here, offline, instead of surfacing as
a silently missing provider. It also asserts that every DSH version pinned in
the CI matrix satisfies the declared range, which is what keeps the two from
drifting apart.
Both suites drive the real entry point rather than a copy of it: apply() is
registered into an actual Context + WebRuntime (ctx.web) and the fetch is
made through ctx.web.fetch(), so a registration mistake — a wrong provider
id, or registering nothing — fails the tests instead of hiding behind
hand-built provider stubs.
devDependencies track the newest DSH line the plugin claims (0.2.0-rc.2),
so the local suite exercises the closure a current harness actually ships.
pack:check exists because a files whitelist mistake is invisible during
development — the whole working tree is present — and only surfaces once a
consumer installs the package. It has already happened here, so the check runs
on every CI run rather than only at release.
While developing outside a profile, node_modules must resolve the harness
packages. Point it at the installation's shared closure:
# Windows (junction; no admin rights needed)
New-Item -ItemType Junction -Path node_modules -Target "$env:USERPROFILE\.dsh\profiles\node_modules"
# POSIX
ln -s "$HOME/.dsh/profiles/node_modules" node_modules
node_modules is gitignored.
The plugin's minor version tracks the DSH line it supports: plugin 0.M.x
supports DSH 0.M.*, and the declared DSH peer range's upper bound is always
<0.(M+1).0-0. So 0.2.0 declares >=0.1.2-rc.1 <0.3.0-0, and the DSH 0.3
line will be adopted as plugin 0.3.0. The full rationale is in the
CHANGELOG; test/compat.test.js derives the
expected upper bound from the manifest's own minor version, so the rule cannot
drift.
Because the range is bounded rather than open-ended, a new DSH patch or prerelease inside the same minor line needs no plugin release — but a new DSH minor line does, and until it ships the gate skips the bundle. That is deliberate: adopting an untested line should be an explicit decision.
| Workflow | Trigger | Purpose |
|---|---|---|
ci.yml | push to main, pull request | Test matrix across the supported DSH versions, packaging guard, live transport lane |
release.yml | tag push v* | Publish to npm via Trusted Publishing (OIDC), then create the GitHub Release |
The test matrix pins each DSH version explicitly rather than resolving by
range. npm's latest tag for the @deepseek-ai/dsh-* sub-packages is stale
(0.0.1-rc.x) even though the @deepseek-ai/dsh package itself publishes the
current release to latest and next alike, so a range would resolve to the
wrong line.
See RELEASING.md for the release process and the one-time npm setup.
This plugin exists because the stock check cannot be satisfied under fake-ip DNS, so the question worth asking is what the relaxation costs.
The relaxation is exactly as wide as the placeholder block. Under fake-ip the proxy performs origin resolution, so upstream reachability is governed by the proxy's own routing rules rather than by this module. Two consequences:
http://127.0.0.1/ stays blocked even though a hostname that
resolves into the placeholder block does not.If you want a stricter posture, narrow fakeIpRanges to your proxy's exact
block (for example 198.18.0.0/16) rather than the wider default.
dns.fake-ip-range; scripts/diagnose.mjs tells
you what to set.text/html, application/xhtml+xml, text/* and the JSON/XML families are
decoded; a missing Content-Type or a binary type throws
WEB_UNSUPPORTED_CONTENT_TYPE.Content-Type header (UTF-8 default) — also
inherited; an HTML <meta charset> declaration is ignored.http id.0.3.0, so a 0.3.x harness skips this bundle until the range is widened.
That is deliberate — an untested major line should be an explicit decision
rather than an assumed one — but it does mean a DSH upgrade can require a
plugin update before web_fetch works again.