DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

kaminn /

kaminn/dsh-web-fetch-fakeip

Verified

This plugin has no description yet.

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@0e0d5f63

description: "A fake-ip-aware WebFetchProvider for the DeepSeek Harness web seam: keeps the web_fetch tool working under mihomo/Clash TUN mode with fake-ip DNS, without disabling fake-ip or setting proxy environment variables." kind: "package-reference"

dsh-web-fetch-fakeip

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.

Summary

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.

Table of Contents

  • Quick start
  • Example configurations
  • Configuration reference
  • What changes, and what does not
  • Diagnosing your setup
  • How it works
  • Development
  • Security notes
  • Known limitations
  • License

Quick start

Install into a profile

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.

Manual installation

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.

Verify

# 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.

Uninstall

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.

Updating

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.

If a DSH update stops the plugin loading

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.


Example configurations

The common case: default mihomo/Clash range

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.

A non-default dns.fake-ip-range

Match 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.

Find your proxy's actual range

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.


Configuration reference

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.

Why 198.18.0.0/15 rather than the 198.18.0.1/16 default

mihomo'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.


What changes, and what does not

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).


Diagnosing your setup

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:

  • fake-ip, covered — this plugin is the right fix; a remaining failure is downstream of destination policy (proxy routing).
  • reserved, not covered — set fakeIpRanges to the reported block.
  • no fake-ip seen — this machine resolves real addresses; the stock provider is already correct and this plugin changes nothing.

To confirm the composed tree actually contains the replacement row:

dsh --profile web --dump-config | grep -B1 -A4 'fakeip\|web-fetch-http'

How it works

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:

  1. Resolve once (or take the literal as stated).
  2. Reject malformed entries (WEB_PROVIDER_ERROR).
  3. If the hostname was not a literal and every answer is a placeholder in a configured block, accept the set as-is.
  4. Otherwise require every answer to be public unicast, exactly as before.

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.

Source map

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)

Why the stock row must be disabled

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.


Development

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.

Versioning

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.

Continuous integration

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.


Security notes

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:

  • Keep the proxy's rules from exposing private ranges to the model's fetch tool. A rule that routes RFC1918 or loopback through the proxy would let a hostname reach an internal service — that risk is introduced by the proxy configuration, not by this plugin, but the plugin is what makes the fetch succeed, so it is worth auditing together.
  • IP literals are never placeholders. They state a destination the caller chose, so handing one to a proxy running on this machine would reach exactly the loopback or private service the check exists to keep out of reach. This is why 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.


Known limitations

  • The range must be configured by hand if it is not the default. The plugin cannot discover the proxy's dns.fake-ip-range; scripts/diagnose.mjs tells you what to set.
  • Only textual content decodes — inherited from the stock provider. 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.
  • Charset comes only from the Content-Type header (UTF-8 default) — also inherited; an HTML <meta charset> declaration is ignored.
  • The stock provider must be disabled. Two providers cannot share the http id.
  • A future DSH line needs a peer-range update. The declared range stops at 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.

License

MIT

—/ 5

No ratings yet

Verified DSH bundle

Commit 0e0d5f630065

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout