@having5548/dsh-downloader 0.0.0-stage → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.en.md ADDED
@@ -0,0 +1,378 @@
1
+ <div align="center">
2
+
3
+ # ⬇️ dsh-downloader
4
+
5
+ **Download proxy plugin for DeepSeek Harness** — foreign targets go through a proxy, domestic ones stay direct. The proxy exit is self-contained (one subscription URL, **no local Clash needed**), and the Chinese AI platform domains are pinned direct so a flapping node or a closed proxy app can never break the session.
6
+
7
+ [简体中文](README.md) | English
8
+
9
+ ![Version](https://img.shields.io/badge/version-0.5.2-4c7ef3?style=flat-square)
10
+ ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-0078d6?style=flat-square)
11
+ ![Protocols](https://img.shields.io/badge/nodes-ss%20%7C%20trojan%20%7C%20vless%20%7C%20vmess%20%7C%20socks5%20%7C%20http-2b6cb0?style=flat-square)
12
+ ![Tests](https://img.shields.io/badge/tests-366%20passed-2fa95e?style=flat-square)
13
+ ![License](https://img.shields.io/badge/license-MIT-green?style=flat-square)
14
+
15
+ </div>
16
+
17
+ ---
18
+
19
+ ## ✨ Features
20
+
21
+ | | | |
22
+ |---|---|---|
23
+ | 🌏 **Foreign via proxy, domestic direct**<br>`dsh_download` decides and downloads in one call — the model never picks a route | 📦 **Self-contained exit**<br>One subscription is enough, **no local Clash required**; accepts Clash YAML and share-link lists, and speaks ss / trojan / vless / vmess / hysteria2 (with salamander obfs) / socks5 / http | 🛡️ **Session keep-alive**<br>22 Chinese AI platform domains pinned direct above every other rule, plus `dsh_session_guard` for `$DSH_HOME/.env` |
24
+ | 🧮 **Offline routing verdict**<br>110k domestic domain suffixes + 8.7k domestic CIDRs; the decision makes **zero network calls** | 🔁 **Automatic fallback**<br>A failed direct attempt (timeout / DNS / reset) is retried through the proxy once, reported as `fallback_used` | 🔐 **Verifiable integrity**<br>sha256 streamed as it goes; the result carries the path, byte count, speed and the route used |
25
+ | 🧯 **No debris on failure**<br>Writes `.part` then renames atomically; **every failure path deletes the partial** | ⏱️ **Large files survive**<br>Host tool timeout deliberately omitted; only your own `downloadTimeoutS` and the stall detector apply | 🖥️ **Settings panel**<br>Status / profile import and management / node list / per-node latency / group select / live traffic / every config field | 🗂️ **FlClash-style profiles**<br>Several configurations (subscription URL / clash:// deep link / local file), each with its own label, last-update time and auto-update interval | 🔒 **Serves this plugin's downloads only**<br>The core binds loopback and requires a per-process token; no other program can reach or borrow it, and the system proxy / environment are never touched |
26
+
27
+ ## ⚠️ Read this first: what it covers
28
+
29
+ This plugin is **tool-type** — it covers only its own downloads.
30
+
31
+ | Egress path | Routed by this plugin? |
32
+ |---|---|
33
+ | the `dsh_download` tool | ✅ yes, with domestic/foreign splitting |
34
+ | the `web_fetch` tool | ❌ no |
35
+ | `curl` / `git` / `npm` / `Invoke-WebRequest` in the shell | ❌ no |
36
+ | processes spawned by jobs / workflow / subagent | ❌ no |
37
+ | MCP server HTTP transports | ❌ no |
38
+
39
+ To route **those** as well you need no plugin at all — put this in `$DSH_HOME/.env` (on Windows: `C:\Users\<you>\.dsh\.env`):
40
+
41
+ ```dotenv
42
+ HTTPS_PROXY=http://127.0.0.1:7890
43
+ HTTP_PROXY=http://127.0.0.1:7890
44
+ NO_PROXY=localhost,127.0.0.1,::1,deepseek.com,.deepseek.com
45
+ ```
46
+
47
+ and restart DSH. DSH reads that file before mounting any plugin and takes over the global dispatcher, `web_fetch`, and every spawned child process. **Only that file may set proxy variables** — a proxy name in a repository's own `.env` makes DSH refuse to start.
48
+
49
+ The two can be used together; they do not interfere. This plugin **never mutates `process.env`** and **never replaces a global dispatcher**.
50
+
51
+ ## 📦 Install
52
+
53
+ From the GitHub release:
54
+
55
+ ```bash
56
+ dsh plugin --profile desktop add https://github.com/having5548/dsh-downloader/releases/latest/download/having5548-dsh-downloader-0.5.2.tgz
57
+ ```
58
+
59
+ Or build and install locally:
60
+
61
+ ```bash
62
+ npm pack
63
+ dsh plugin --profile desktop add having5548-dsh-downloader-0.5.2.tgz
64
+ ```
65
+
66
+ > Profile name: the current Electron desktop app uses `desktop`; the older web CLI used `web`.
67
+
68
+ Then open **Settings → Download proxy**.
69
+
70
+ ## 🚀 Quick start
71
+
72
+ 1. Open **Settings → Download proxy**.
73
+ 2. Import nodes in the **"Node profiles"** card — the three ways FlClash offers:
74
+ - **From a subscription URL**: paste an `https://…`, or a FlClash / Clash Verge style `clash://install-config?url=…` deep link (the URL inside is extracted for you).
75
+ - **From a file**: pick a local `.yaml` / `.yml` configuration.
76
+ - **Leave the name blank** and it is derived the FlClash way: the `Content-Disposition` filename first, then the host.
77
+ Each profile is stored separately with its own **last-update time** and **auto-update toggle/interval** (24 h by default; file profiles never auto-update, same as FlClash).
78
+ 3. "Use" switches the active profile, "Update" re-downloads it, "Delete" removes it.
79
+ 4. Or skip the UI entirely and let the model import with the `dsh_profile` tool:
80
+
81
+ > Import this subscription: https://example.com/api/v1/client/subscribe?token=xxx
82
+
83
+ 5. Ask the model to download something foreign:
84
+
85
+ > Download https://github.com/X/Y/releases/latest/download/app.zip to D:\dl
86
+
87
+ 6. Read `route` from the result: `proxy` = through a node, `direct` = domestic, `fallback_used: true` = the direct attempt failed and the proxy was used.
88
+
89
+ > The legacy single `subscriptionUrl` field still works: it is consulted only when **no profile exists**. As soon as you import one, the selected profile wins.
90
+
91
+ ## 🔑 Authenticated downloads (GitHub Actions artifacts and friends)
92
+
93
+ `dsh_download` can carry credentials, but it only accepts a **reference**, never a literal token — tool arguments stay in the session log forever:
94
+
95
+ ```
96
+ dsh_download({ url: "<archive_download_url>", save_path: "...", auth: "gh" })
97
+ ```
98
+
99
+ | `auth` value | Meaning |
100
+ | --- | --- |
101
+ | `gh` | The plugin runs `gh auth token --hostname github.com` itself and reads GitHub CLI's own credential store. **Recommended** |
102
+ | `env:NAME` | Read an environment variable, e.g. `auth: "env:GH_TOKEN"` |
103
+ | `bearer:token` | A literal token — convenient, but it lands in the session log; use only for non-sensitive cases |
104
+
105
+ `headers` adds arbitrary request headers (string keys and values):
106
+
107
+ ```
108
+ dsh_download({ url: "...", save_path: "...", headers: { "X-Api-Key": "..." } })
109
+ ```
110
+
111
+ Headers that would break the request (`Host`, `Content-Length`, `Connection`, `Transfer-Encoding`, `Proxy-Authorization`) are **silently ignored** rather than allowed to corrupt the download.
112
+
113
+ **Credentials are dropped on a cross-host redirect, on purpose**: GitHub's `archive_download_url` 302s to a blob host, where the `Authorization` header is useless, hands your token to a third party, and is sometimes answered with a 400. So `Authorization` / `Cookie` are stripped when the redirect changes host, and kept when it does not.
114
+
115
+ Credentials apply to **every** request: the initial probe, each segmented Range request, and the single-stream retry after a fallback.
116
+ ## ⚡ Multi-threaded downloads
117
+
118
+ When the server supports `Range`, a large file is split across `threads` (4 by default) connections.
119
+
120
+ **The point is not "cut it into N parts" but how you cut it.** Equal static splits are only fast when every connection is equally fast — one slow connection drags the whole transfer (`total ≈ slowest part × parts / concurrency`). So this follows gopeed (`internal/protocol/http/fetcher.go`):
121
+
122
+ - start with an even `threads`-way split;
123
+ - **whichever connection finishes first steals the front half of the chunk with the most bytes left** (never below 512 KiB — smaller slices are not worth a request);
124
+ - a slow chunk therefore keeps getting cut down while fast connections stay busy, until everything is claimed.
125
+
126
+ | Case | Behaviour |
127
+ | --- | --- |
128
+ | No `accept-ranges: bytes` | single connection |
129
+ | File below 1 MiB | single connection (coordination costs more than it saves) |
130
+ | `threads` = 1 | single connection |
131
+ | Server **claims Range support but answers 200** | detected, then **falls back to one stream**, never a corrupt file |
132
+ | `Content-Range` start mismatch, or the slices do not sum to the declared size | same fallback |
133
+
134
+ Slices write at their absolute offset in one file (`FileHandle.write` with a position), and sha256 is computed by streaming the merged file afterwards — slices land out of order, so hashing as they arrive would produce the wrong digest.
135
+
136
+ `via_threads` and `segments` in the result say which path was actually taken; the panel's "Download" card lets you change `threads`.
137
+
138
+ ## 📊 Live traffic in the sidebar
139
+
140
+ A traffic badge sits in the sidebar foot (next to Settings) showing live up/down rates and cumulative bytes through this plugin's rule core:
141
+
142
+ ```
143
+ ↑ 1.2 MB/s 340 KB
144
+ ↓ 3.4 MB/s 1.8 GB
145
+ ```
146
+
147
+ - The numbers come from the core's byte counters (`/traffic`) and cover **only this plugin's traffic**, not the whole NIC.
148
+ - Collapsed to the 56px rail it switches to a minimal layout showing the download rate only.
149
+ - Rates are deltas between samples, so the core caches a sample for 1.2 s — otherwise the panel (every 5 s) and the badge (every 1.5 s) would keep stealing each other's window and the reading would jump.
150
+ - Polling drops to 5 s while the page is hidden.
151
+ - The badge takes no space when the core is not running.
152
+ ## 🔒 Proxy scope: only downloads this plugin starts
153
+
154
+ The proxy serves **only** download requests this plugin initiates. That is not a convention — it is enforced by three hard constraints:
155
+
156
+ | Constraint | Effect |
157
+ |---|---|
158
+ | **Binds `127.0.0.1` only** | No other machine on the LAN can reach the listener at all |
159
+ | **Per-process random token** | Generated in-process next to the download client; never persisted, never logged. A connection without it gets `407`; SOCKS5 is refused outright because its greeting cannot carry the token |
160
+ | **No system proxy, no environment mutation** | Browsers, other apps, and any DSH egress that is not this plugin stay exactly as they were |
161
+
162
+ `dsh_proxy_status` reports all three in its `scope` field, and the "Upstream" card in the panel states them too.
163
+
164
+ **In other words**: even if something scanned the port it could not use it — and no traffic other than `dsh_download` has ever passed through this plugin.
165
+
166
+ ## 🧩 git / curl downloads also go through this proxy
167
+
168
+ `git` and `curl` run in shell child processes, and a tool-type plugin cannot intercept them out of process. So this is implemented as **two halves**:
169
+
170
+ **① A tool that hands the model a proxied command** (`dsh_run_proxied`):
171
+
172
+ ```
173
+ dsh_run_proxied({ command: "git clone https://github.com/x/y.git" })
174
+ ```
175
+
176
+ It injects the proxy environment (`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` and the lowercase forms, pointing at this plugin's core) for **that one** shell call, then hands it to the **ordinary shell tool** — the sandbox and permission presets still apply; this is not a side door. Domestic targets are still resolved by the core, and the Chinese AI platform domains go into `NO_PROXY` so they never even reach it.
177
+
178
+ With `execute=false` it only returns the composed command, for you or the model to run through `pwsh` directly.
179
+
180
+ **② A tool guard** (`guardShellDownloads`, on by default):
181
+
182
+ When a shell command looks like a foreign download without a proxy (`git clone|fetch|pull|submodule`, `curl`, `wget`, `Invoke-WebRequest`, with a target the offline rules classify as foreign), it is **denied** with a message pointing at `dsh_run_proxied` or `dsh_download`.
183
+
184
+ Not denied: a command that already carries a proxy (`HTTP_PROXY`, `curl -x`, `git -c http.proxy`); loopback/private targets; when the core has no usable exit (blocking with no alternative is worse than not blocking); and SSH remotes (`git@github.com:…` is not HTTP, so a proxy environment variable cannot help it).
185
+
186
+ Turn it off in settings if it gets in the way.
187
+
188
+ **`git` gets two extra config entries**: besides the `*_PROXY` variables, `dsh_run_proxied` feeds git
189
+ `http.proxy` (with credentials) and `http.proxyAuthMethod=basic` through `GIT_CONFIG_COUNT`. The reason is that
190
+ **git does not send `Proxy-Authorization` preemptively the way curl does** — with environment variables alone it
191
+ gets a `407`. Both entries are harmless to non-git commands, so no command sniffing is involved.
192
+
193
+ The core's `407` challenge says **`Basic`** (both are accepted: `Bearer <token>` and `Basic dsh:<token>`, but only
194
+ Basic is a scheme every client can answer). With `Bearer`, curl/git see a scheme they cannot satisfy and give up
195
+ without retrying.
196
+
197
+ **For a plain file download, prefer `dsh_download`** — it brings sha256, the size cap, the stall detector and fallback. `dsh_run_proxied` exists for cases where git/curl itself is required (cloning a repository, calling an API).
198
+
199
+ ## 🛡️ Session guard: Chinese AI platforms never go through a proxy
200
+
201
+ A proxy node that flaps kills every long-lived connection routed through it, and every model endpoint this harness talks to is a domestic service that never needed a proxy. This takes two layers, because a tool-type plugin cannot touch the LLM connection.
202
+
203
+ ### Layer 1 — the download path (this plugin's core, on by default)
204
+
205
+ The platform domains below become `DOMAIN-SUFFIX,<domain>,DIRECT` rules placed at **the highest priority** — ahead of the subscription's own rules, ahead of your `extraRules`, and **still direct when `domesticDirect=false` (everything-through-the-proxy mode)**:
206
+
207
+ > DeepSeek · 智谱/Z.ai/ChatGLM · Moonshot Kimi · Alibaba Tongyi/DashScope · ByteDance Doubao/Volcano Ark ·
208
+ > Baidu ERNIE/Qianfan · Tencent Hunyuan · iFlytek Spark · MiniMax · 01.AI · StepFun · SenseTime ·
209
+ > Baichuan · SiliconFlow · Kunlun Tiangong · Huawei Cloud Pangu/ModelArts · NetEase Youdao · Langboat ·
210
+ > XVERSE · ModelBest · Mobvoi · SCNet / OpenI
211
+
212
+ The full list lives in [`lib/core/ai-domains.js`](lib/core/ai-domains.js) (a stale entry is harmless — it just never matches). `extraDirectDomains` adds your own domains or full URLs.
213
+
214
+ ### Layer 2 — the harness's own model connections (one click)
215
+
216
+ What decides where model traffic goes is DSH's launch-time global proxy policy (`HTTP_PROXY` / `HTTPS_PROXY` in `$DSH_HOME/.env`). Close the proxy app and those connections die — the session dies with them.
217
+
218
+ Fix it with the `dsh_session_guard` tool (or the "Session guard" card in Settings):
219
+
220
+ | Action | Effect |
221
+ |---|---|
222
+ | `check` (default) | Read-only: reports whether that file sets proxy vars and whether the AI domains are already in `NO_PROXY` |
223
+ | `apply` | Merges the missing domains into `NO_PROXY`. **Backs the file up first**, and **only ever edits that one line** — `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` are never touched |
224
+ | `restore` | Puts the newest backup back; fails loudly when there is none |
225
+
226
+ Each domain is written in both spellings (`d` and `.d`): DSH matches a bare suffix against subdomains, older curl/git want the leading dot. Other lines and the file's existing line endings are preserved.
227
+
228
+ > DSH reads that file once at launch, so **a restart is required after `apply`** — the tool result says so too.
229
+
230
+ ## ⚙️ Configuration
231
+
232
+ The upstream is resolved in this order; the first hit wins:
233
+
234
+ | # | Mode | Condition |
235
+ |---|---|---|
236
+ | 1 | `subscription` | a profile exists or `subscriptionUrl` is set — **self-contained, no local Clash needed**, and nodes are selectable |
237
+ | 2 | `explicit` | `proxyUrl` is set (`http://` or `socks5://`), or the subscription yielded no nodes |
238
+ | 2 | `environment` | not set, but `$HTTPS_PROXY` / `$HTTP_PROXY` is (the `.env` above) |
239
+ | 3 | `environment` | neither is set, but `$HTTPS_PROXY` / `$HTTP_PROXY` has a value (the one from `.env`) |
240
+ | 4 | `none` | none of the above: foreign targets fail with an actionable message, domestic ones still download directly |
241
+
242
+ | Field | Default | Meaning |
243
+ |---|---|---|
244
+ | `enabled` | `true` | master switch |
245
+ | `proxyUrl` | empty | explicit upstream |
246
+ | `subscriptionUrl` | empty | single subscription URL (same source as a profile import); **takes priority over `proxyUrl`** |
247
+ | `fetchProxyUrl` | empty | used only to fetch the subscription itself (must be `http(s)://`) |
248
+ | `autoUpdateHours` | `24` | refresh interval of the legacy single subscription, and the default interval for newly imported profiles; `0` disables |
249
+ | `groupType` | `url-test` | `url-test` / `select` / `fallback` |
250
+ | `preferredNode` | empty | pin one node by name |
251
+ | `latencyTestUrl` / `latencyTimeoutMs` | gstatic 204 / `3000` | node health-check URL and timeout |
252
+ | `domesticDirect` | `true` | turn off to send everything through the proxy (**AI platforms still direct**) |
253
+ | `protectAiPlatforms` | `true` | pin the Chinese AI platform domains direct at the highest priority |
254
+ | `extraDirectDomains` | `[]` | extra domains to force direct (full URLs and `*.x.com` accepted) |
255
+ | `guardShellDownloads` | `true` | deny foreign shell downloads that carry no proxy, pointing at `dsh_run_proxied` |
256
+ | `extraRules` / `excludeRules` | `[]` | add / drop rule lines |
257
+ | `downloadDir` | empty | empty = `$DSH_HOME/downloads` |
258
+ | `maxDownloadMb` | `512` | per-file size limit |
259
+ | `downloadTimeoutS` | `600` | per-download wall-clock budget |
260
+ | `threads` | `4` | segmented connection count; `1` = single stream. Falls back to one connection when the server lacks `Range`, answers it dishonestly, or the file is under 1 MiB |
261
+ | `stallTimeoutS` | `30` | abort after this long with no data |
262
+ | `insecureTls` | `false` | skip TLS verification (corporate TLS interception only) |
263
+ | `allowOutsideWorkspace` | `false` | allow `save_path` outside the workspace and download directory |
264
+ | `maxRedirects` | `10` | maximum redirect hops |
265
+
266
+ Data directory: `$DSH_HOME/dsh-downloader/` — `profiles.json` (the profile list) plus `profiles/<id>.yaml` (each imported configuration), alongside the legacy `subscription.yaml` cache and `state.json` node selection.
267
+
268
+ ## 🧰 Tools
269
+
270
+ | Tool | Purpose |
271
+ |---|---|
272
+ | `dsh_download` | Download a file. `url` required; optional `save_path` / `overwrite` / `force_route` / `max_mb` / `timeout_seconds`. Returns `saved_to`, `bytes`, `sha256`, `speed_bps`, `route`, `route_reason`, `fallback_used`. |
273
+ | `dsh_proxy_status` | Upstream mode, subscription state, node count and latencies, selected node, core port, AI-protection state, session-guard state, last error. |
274
+ | `dsh_geo_check` | Verdict on whether a URL or host goes direct or through the proxy. **Uses the embedded offline rule tables first (zero network)**; DNS + online GeoIP + ping only when the tables cannot decide. |
275
+ | `dsh_session_guard` | Checks / writes / restores `NO_PROXY` in `$DSH_HOME/.env` (see the session guard above). |
276
+ | `dsh_profile` | Manages node profiles (FlClash-style): list / import from a subscription URL or a `clash://` deep link / import from configuration text / update / select / delete / toggle auto-update / reorder / rename. |
277
+ | `dsh_run_proxied` | Runs a `git` / `curl` style shell download through this plugin's proxy, for that one call only (still via the ordinary shell tool and its permission policy). |
278
+
279
+ It also registers a runtime skill, **`smart-download`**, which makes the model prefer `dsh_download` over `curl` for internet downloads.
280
+
281
+ ### Routing semantics
282
+
283
+ - **The verdict is offline**: 110k domestic domain suffixes + 8.7k domestic CIDRs, plus the subscription's own rules and your `extraRules`, matched as `DOMAIN` / `DOMAIN-SUFFIX` / `DOMAIN-KEYWORD` / `IP-CIDR` / `REJECT`. No network call, so a blocked GeoIP service cannot break it.
284
+ - **Chinese AI platform domains are always direct**: inserted ahead of every other rule and not overridable by a user rule (unless `protectAiPlatforms` is off).
285
+ - **Loopback and private addresses never enter a proxy** (`127.0.0.0/8`, `::1`, `.local`, private ranges) — not even with `force_route=proxy`.
286
+ - A `direct` verdict **never touches the core**; a `proxy` verdict goes through the local core → node.
287
+ - When a direct attempt fails and an upstream is available, the download is **retried through the proxy once**, reported as `fallback_used: true`.
288
+
289
+ ### Integrity semantics
290
+
291
+ - Writes `<target>.part` first and only then renames atomically; **every failure path removes the `.part`**.
292
+ - sha256 is computed while streaming, the byte cap is enforced while streaming, a stall detector aborts a dead transfer, and `exec.signal` cancels the whole thing.
293
+ - The host tool timeout is deliberately omitted (DSH's `timeoutMs` is opt-in), so large files are never cut off by the framework — only by `downloadTimeoutS`.
294
+
295
+ ## 🌐 Node protocols
296
+
297
+ | Protocol | Supported |
298
+ |---|---|
299
+ | `socks5` / `http` | ✅ |
300
+ | `ss` (aes-128-gcm / aes-256-gcm / chacha20-ietf-poly1305) | ✅ |
301
+ | `trojan` (tcp / tls / ws) | ✅ |
302
+ | `vless` (tcp / tls / ws) | ✅ |
303
+ | `vmess` (AEAD alterId=0, tcp / ws) | ✅ |
304
+ | `hysteria2` (incl. `salamander` obfs) | ✅ bundled Go connector (Windows amd64) |
305
+ | `vless reality` | ✅ same connector |
306
+ | `tuic` | ❌ |
307
+
308
+ **The native connector is bundled**: `lib/native/connector.exe` (self-compiled, with hysteria2's salamander obfs and vless reality). `dsh_proxy_status` reports `native connector: present`. On platforms other than Windows amd64 those nodes are skipped and counted; other nodes are unaffected.
309
+
310
+ ## 📥 Subscription formats
311
+
312
+ | Format | Notes |
313
+ |---|---|
314
+ | Clash / mihomo YAML | `proxies` + `proxy-groups` + `rules`, fully supported |
315
+ | **base64-wrapped YAML** | what a `?flag=clash` style endpoint often returns |
316
+ | **Share-link lists** (base64 or plain) | one `hysteria2://` / `ss://` / `trojan://` / `vmess://` / `vless://` / `socks5://` / `http(s)://` per line — one of the most common provider formats |
317
+
318
+ Share links are converted into node objects equivalent to Clash YAML, so nothing downstream (rule engine, transports) had to change. Lines that cannot be read are **counted and reported** (`订阅已加载 9 个节点(跳过 2 条)`) rather than silently dropped.
319
+
320
+ > `ss` with a plugin (`?plugin=…`) and `tuic` are explicitly reported as unsupported instead of pretending to load.
321
+
322
+ ## 🔗 How it relates to DSH's built-in proxy
323
+
324
+ - This plugin **never writes `process.env`** and **never calls `setGlobalDispatcher`**, so there is no unload residue and no fight with the launcher's policy.
325
+ - In core mode, download traffic is `node:http` to `127.0.0.1:<local port>` and never passes through undici's global dispatcher.
326
+ - The one indirect touch: subscription fetching uses the plugin's own HTTP client, falling back to `$HTTPS_PROXY` / `$HTTP_PROXY` when `fetchProxyUrl` is empty.
327
+
328
+ ## 🔧 How it works
329
+
330
+ ```
331
+ model → dsh_download
332
+ │
333
+ ├─ 1. resolve the upstream explicit / environment / subscription / none
334
+ ├─ 2. decide offline embedded CN tables + AI protection + subscription rules + extraRules
335
+ │
336
+ ├─ direct ──────────► connect straight to the target
337
+ └─ proxy ──────────► 127.0.0.1:<random port> loopback mixed proxy
338
+ │
339
+ RuleEngine decides
340
+ ├─ direct → connect straight
341
+ └─ proxy → connectThrough(node)
342
+ ss / trojan / vless / vmess /
343
+ socks5 / http / hysteria2
344
+
345
+ the rest of the system, and DSH's other egress — completely unaffected
346
+ ```
347
+
348
+ The core (rule engine, CN data, loopback mixed proxy, node transports, subscription parsing) is derived from `dsh-clash-proxy` 0.2.0 (MIT); every derived file keeps a provenance header. This repository **fixes a real truncation bug** on top of it: `SocketBuffer.readAny()` consulted `#ended` before its buffer, so bytes that had already arrived but had not yet been consumed when the socket closed were silently dropped — measured as a 512 KiB response truncated to 384 KiB, i.e. a silently corrupt download. See the `[dsh-downloader]` marker in `lib/core/transports.js`.
349
+
350
+ ## 🧪 Development
351
+
352
+ ```bash
353
+ npm install
354
+ node test/smoke.mjs # 366 checks: rules / subscriptions / profile import / routing / HTTP client / end-to-end / session guard / token gate
355
+ node --check lib/client.js
356
+ npm pack
357
+ ```
358
+
359
+ `test/smoke.mjs` needs **no internet**: it starts a local HTTP fixture server and drives real downloads through the plugin's own `ProxyServer`, verifying sha256 byte-for-byte and covering the size cap, the stall detector, cancellation, 404, redirects, `.part` cleanup, plus assertions like "AI domains stay direct under the strictest rule", "a connection without the token gets 407", and "apply never introduces a proxy variable".
360
+
361
+ ## ⚠️ Known limitations
362
+
363
+ | Limitation | Detail |
364
+ |---|---|
365
+ | Own downloads only | See the boundary table at the top; use `$DSH_HOME/.env` for the rest |
366
+ | The `save_path` check is not a sandbox | Writes use `node:fs` streaming (`ctx.fs` has no streaming write), so it is "policy-checked admission, host-level write" |
367
+ | Filename is derived from the URL by default | `Content-Disposition` is only visible in the result; pass an explicit `save_path` when the name matters |
368
+ | No live progress bar | DSH tools have no MCP-style `report()` channel; progress is only in the final `bytes` / `speed_bps` |
369
+ | The native connector is Windows-amd64 only | `lib/native/connector.exe` is a Windows amd64 binary; other platforms skip hysteria2 / reality nodes and count them |
370
+ | One data directory per `$DSH_HOME` | Multiple DSH instances share the profile list, subscription cache and node selection |
371
+ | SOCKS5 inbound is refused in token mode | A SOCKS5 greeting cannot carry the token, so the protocol is refused outright; this plugin's own client uses HTTP CONNECT and is unaffected |
372
+ | The session guard writes outside the workspace | Only when `dsh_session_guard action="apply"` (or the panel button) is called explicitly: it backs `$DSH_HOME/.env` up first and edits only the `NO_PROXY` line |
373
+
374
+ ## 📄 License
375
+
376
+ MIT. The core is derived from `dsh-clash-proxy` 0.2.0 (MIT); see the file headers and [LICENSE](LICENSE).
377
+
378
+ This plugin only provides technical capability — make sure your proxy service and its use comply with your local laws and regulations.