# dsh-proxy-auto [![tests](https://github.com/cijiezhi/dsh-proxy-auto/actions/workflows/ci.yml/badge.svg)](https://github.com/cijiezhi/dsh-proxy-auto/actions/workflows/ci.yml) [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) **A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) (DSH) plugin that makes the host follow this machine's proxy at runtime — and adds a tool that can actually read pages behind it.** Turn your proxy on or off: DSH follows it within ~3 seconds, **no restart**. Nothing is hard-wired, so a proxy that disappears can never take the app's connectivity down with it. [中文说明](README.md) · [Install & troubleshooting](INSTALL.md) · [Changelog](CHANGELOG.md) --- ## Install **Option 1 — straight from this repository** (for CLI-managed profiles such as `tui` / `web`): ```sh dsh plugin --profile add github:cijiezhi/dsh-proxy-auto ``` This plugin has **no build step** (no `prepare` / `postinstall`), so pnpm's `allowBuilds` never blocks it. **Option 2 — the local installer** (required for the `desktop` profile, which the CLI refuses to manage): ```powershell cd pwsh -File .\install.ps1 # idempotent; -Profile web, -Check (dry run), -Uninstall ``` **Option 3 — the zip attached to a Release**, extracted, then run `install.ps1` as above. Then restart DSH once (the host half is loaded at startup). To verify: ```powershell pwsh -File .\verify.ps1 ``` ## Why it exists DSH resolves its outbound proxy policy **once, at launch**, from the proxy environment variables. Two everyday consequences: 1. Changing your proxy (or its port) means restarting the app, otherwise the stale address keeps being used. 2. Once a proxy is written into the environment, **turning the proxy off breaks everything** — DSH keeps dialing a port that no longer exists. There is also a capability gap: the built-in `web_fetch` tool pins IPs with its own undici `Agent` (SSRF / DNS-rebinding protection) and therefore **deliberately bypasses the global dispatcher** — it always connects directly, so blocked pages can never be fetched. That is by design and no plugin can change it. This plugin closes those gaps: **follow the local proxy, fall back to direct when it disappears, and provide a fetch tool that can reach blocked sites.** ## What it does | Ability | Detail | |---|---| | **Follows the proxy** | Every 3 s it detects the local proxy (env vars / Windows system-proxy registry + **TCP liveness check**) | | **No restart to toggle** | Swaps the global `fetch` dispatcher on demand: proxy → `EnvHttpProxyAgent`, none → bare `Agent` | | **`proxy_fetch` tool** | Fetches readable page text using an **explicit dispatcher**, so it works for blocked sites while the proxy is up | | **Child processes** | Maintains DSH's official `.env` so `curl` / `git` / `npm` inherit the proxy too | | **Status snapshot** | `$DSH_HOME/cache/dsh-proxy-auto/state.json`, refreshed every 3 s — inspect whether traffic is proxied right now | ## Will the model actually use it? The easy trap: **registering a tool does not make the model call it.** The built-in `web_fetch` sits in the same tool list, and for "fetch a web page" the model tends to pick the more obvious name — but `web_fetch` bypasses every proxy by design, so it always fails on blocked sites. Users then conclude the plugin "does nothing". Two things fix self-invocation: 1. **A system-prompt section** (order 2150, right after the official `web_fetch` section) stating that `web_fetch` always connects directly, that blocked pages should go through `proxy_fetch`, and that the model must not report a site as unreachable before trying it. Disable with `promptHint: false`, or replace the wording with `promptHintText`. 2. **A stronger tool description** (the only model-visible metadata): prefer it for sites that may be blocked; retry the same URL with it after `web_fetch` fails or times out. To confirm it is really injected, look at `promptHint` in the snapshot: ```jsonc "promptHint": { "registered": true, "name": "dsh-proxy-auto:web-fetch-hint", "order": 2150, "chars": 429 } ``` If `registered: false`, a `reason` says why; the tool still works, the model just has to choose it itself. **Failure fallback (B)**: if the model still calls the built-in `web_fetch` first (which always fails on blocked sites), the plugin attaches a corrective context to that call on the `tools/post-execute` event — "`web_fetch` always connects directly by design; retry the same URL with `proxy_fetch`". The original error is left untouched, both `accept` and `block` decisions get the context (`block` keeps its `feedback`), and `fetchFallback: false` disables it. To confirm it actually fired, read `fetchFallback` in the snapshot — it answers both "is it installed" and "why did it never inject". ```jsonc "fetchFallback": { "installed": true, "injected": 1, "calls": 4, "matched": 1, "skippedKind": 0, "tools": ["pwsh", "web_fetch"], "errorTools": ["web_fetch"], "kinds": { "accept": 4 }, "lastDecisionKind": "accept", "lastError": null } ``` `injected` counts corrections; `calls` / `matched` / `skippedKind` / `kinds` locate a silent failure: `calls: 0` = the hook never ran; `matched: 0` = tool name or error flag mismatch (see `errorTools`); `skippedKind > 0` = unexpected decision kind (see `kinds`). **How A and B divide the work**: A (prompt section) makes the model pick the right tool most of the time; B (fallback) recovers the case where it picks the wrong one. ## What it does NOT do - **It does not change `web_fetch`** (official design) — use `proxy_fetch` for blocked pages. - **No SOCKS, no PAC parsing** — such values are treated as "direct" (the official seam only accepts http(s)). - **On non-Windows it only reads environment variables** (no system-proxy registry). - No UI, and it never touches your proxy client's own settings. ## Behaviour on failure Every step is wrapped, and the rule is: **never make a working direct connection worse.** - A candidate proxy is adopted only after a **TCP liveness check**; unreachable means "no proxy". - When the proxy disappears, the policy is removed **within ~3 s** and traffic falls back to direct — no dangling address is left behind. - Any setup failure (host package missing, `.env` not writable, …) is logged only; the rest keeps working. - It never sets `NODE_USE_ENV_PROXY`, which was measured to make Node ignore a caller-supplied dispatcher. ## Usage Once installed, just let the agent fetch a blocked page (proxy on): ```text proxy_fetch https://en.wikipedia.org/wiki/Naruto URL: https://en.wikipedia.org/wiki/Naruto Status: 200 (truncated) Via: http://127.0.0.1: ``` ## Configuration (all optional) | Field | Default | Meaning | |---|---|---| | `watchIntervalMs` | 3000 | detection interval | | `probeTimeoutMs` | 400 | TCP liveness timeout | | `probeUrl` | a wikipedia page | self-check probe (empty = off); result lands in the snapshot's `probe` | | `persistToEnv` / `envFile` | true / `$DSH_HOME/.env` | child-process environment | | `tool` | true | register `proxy_fetch` | | `promptHint` | true | inject a **system-prompt section** telling the model to prefer `proxy_fetch` (see below) | | `promptHintText` | empty | custom hint text (empty = built-in default) | | `fetchFallback` | true | **failure fallback**: when the built-in `web_fetch` fails, tell the model to retry with `proxy_fetch` | | `noProxy` | `localhost,127.0.0.1,::1,api.deepseek.com` | always-direct list (model API stays direct, so a dead proxy never breaks chat) | | `debug` | false | log every decision | ## Development ```sh node test/standalone.mjs # 9 tests (3 pass / 6 skip here): host-package resolution without junctions / schema / tool contract node test/selftest.mjs # 37 tests (36 pass / 1 skip here): detection guarantees / official seam install & removal / .env / dispatcher / prompt section / failure fallback ``` On a machine without DSH, host-dependent cases are skipped automatically (that is the CI case), so anyone can clone and run them. ## License [MIT](LICENSE)