@having5548/dsh-downloader 0.5.2 → 0.6.0
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/CHANGELOG.md +55 -0
- package/README.en.md +14 -6
- package/README.md +13 -5
- package/lib/core/harness-proxy.js +192 -0
- package/lib/core/proxy-server.js +29 -10
- package/lib/core/transports.js +55 -22
- package/lib/core/upstream.js +38 -2
- package/lib/index.js +109 -0
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,61 @@
|
|
|
9
9
|
|
|
10
10
|
## [未发布]
|
|
11
11
|
|
|
12
|
+
## [0.6.0] - 2026-10-17
|
|
13
|
+
|
|
14
|
+
### 新增
|
|
15
|
+
|
|
16
|
+
- **`harnessProxy`:让 harness 自身的出网也走内核**(原先只有 `dsh_download` 与 `dsh_run_proxied` 走)。
|
|
17
|
+
做法是把内核绑到**固定端口**、额外接受一个**持久化令牌**,再把
|
|
18
|
+
`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` 写进 `$DSH_HOME/.env`(写前备份)。
|
|
19
|
+
DSH 启动时据此装全局 dispatcher 并把变量传给子进程,因此**这些会跟着走**:
|
|
20
|
+
`web_fetch`、shell 子进程里的 `curl`/`git`/`npm`、MCP 的 HTTP 传输、jobs/workflow/subagent 派生的进程。
|
|
21
|
+
**覆盖不到** Electron 渲染进程与内嵌 webview —— Chromium 读的是**系统代理**,不读环境变量。
|
|
22
|
+
- 新工具 `dsh_harness_proxy`(`status` / `apply` / `remove`),新配置项
|
|
23
|
+
`harnessProxy`(开关)与 `harnessProxyPort`(默认 `17890`)。
|
|
24
|
+
- 新模块 `lib/core/harness-proxy.js`:令牌持久化、URL 拼装、`.env` 的增/删/查、备份。
|
|
25
|
+
|
|
26
|
+
### 设计要点
|
|
27
|
+
|
|
28
|
+
- **令牌必须持久化**:`.env` 里要写死它,随机令牌每次重启都会 407。存在
|
|
29
|
+
`<数据目录>/harness-token.txt`(`mode 0o600`)。
|
|
30
|
+
- **端口必须固定**:`.env` 指不到随机端口。被占用时自动退回随机端口并 warn —— 此时开放实际不生效(`status` 会报「与配置不一致」)。
|
|
31
|
+
- **`NO_PROXY` 自动补上回环 + 国内 AI 平台域名**:前者避免「代理指向自己」的循环,后者保证代理抖动时不打断模型长连接。
|
|
32
|
+
- **不会有自指环**:内核与订阅自己拉的流量走 `httpFlow`(Node `http/https` 模块),**不受全局 dispatcher 影响** —— 这是刻意确认过的,插件里没有用 `fetch()` 的核心代码。
|
|
33
|
+
- 内核原先只接受一个令牌,现改为**令牌集合**(本进程令牌 + 持久化令牌),unknown 令牌照旧 407。
|
|
34
|
+
|
|
35
|
+
### 兼容
|
|
36
|
+
|
|
37
|
+
`harnessProxy` 默认**关闭**,行为与 0.5.3 完全一致;不开时内核仍是随机端口 + 随机令牌,只服务本插件自己的下载。
|
|
38
|
+
|
|
39
|
+
> 测试 368 → 406 项:令牌持久化与只读模式、URL 校验、6 个 `*_PROXY` 键的增删幂等、
|
|
40
|
+
> 备份与还原、`NO_PROXY` 合并、不一致检测,以及**内核同时接受两个令牌 / 未知令牌仍被拒**的运行时断言。
|
|
41
|
+
|
|
42
|
+
## [0.5.3] - 2026-10-11
|
|
43
|
+
|
|
44
|
+
### 修复
|
|
45
|
+
|
|
46
|
+
- **上游断管会把整个 DSH 宿主崩掉**(严重,宿主连续崩了两次)。
|
|
47
|
+
现象:`crash-2026-10-11T03-46-46-081Z-host.log` 与 `03-48-03-999Z-host.log` 的栈指向同一处:
|
|
48
|
+
```
|
|
49
|
+
dsh: fatal uncaught exception: Error: write EPIPE
|
|
50
|
+
at Writable.write (node:internal/streams/writable:508:10)
|
|
51
|
+
at Object.write (.../@having5548/dsh-downloader/lib/core/transports.js:598:28)
|
|
52
|
+
at Socket.<anonymous> (.../@having5548/dsh-downloader/lib/core/proxy-server.js:154:9)
|
|
53
|
+
```
|
|
54
|
+
根因:`nativeConnect()` 用 `stdio: ["pipe","pipe","pipe"]` 起 Go 连接器,但**三根管道一个 `error` 监听都没挂**。
|
|
55
|
+
连接器进程一死(连接失败 / 超时 / 被 kill),管道随即断裂,之后任何一次写入都会让 `child.stdin` 抛出
|
|
56
|
+
`EPIPE` —— 而在 Node 里**没有监听者的 `error` 事件就是未捕获异常**,直接把宿主带走。
|
|
57
|
+
原有的 `ended` 标志挡不住这个竞态:写入可能排在一个已经断掉的管道上,而 `exit` 事件还没派发。
|
|
58
|
+
修法(三层):
|
|
59
|
+
1. 新增 `stdioFace(child)`:给 `stdin` / `stdout` / `stderr` 与子进程本身都挂 `error` 监听,
|
|
60
|
+
写入再包一层 try/catch(防同步抛出),并保留连接器最后的 stderr 供排查;
|
|
61
|
+
2. `ProxyServer.#relay()` 里的 `face.write(chunk)` 包 try/catch,失败即关闭上游并销毁客户端连接;
|
|
62
|
+
3. 新增**独立子进程**回归测试:让子进程关掉 stdin 但继续活着(于是 `ended` 不会置位、管道却已断),
|
|
63
|
+
再灌 1.25 MB 数据。旧写法在该探针下必崩(实测 `exit=1`、`code: 'EOF'`),修复后存活。
|
|
64
|
+
|
|
65
|
+
> 测试 366 → 368 项。
|
|
66
|
+
|
|
12
67
|
## [0.5.2] - 2026-10-10
|
|
13
68
|
|
|
14
69
|
两处都是把 `dsh_run_proxied` 真正用起来时才暴露的:`git push` 打插件内核**必定**失败。
|
package/README.en.md
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
[简体中文](README.md) | English
|
|
8
8
|
|
|
9
|
-

|
|
10
|
+

|
|
10
11
|

|
|
11
12
|

|
|
12
|
-

|
|
13
14
|

|
|
14
15
|
|
|
15
16
|
</div>
|
|
@@ -50,20 +51,27 @@ The two can be used together; they do not interfere. This plugin **never mutates
|
|
|
50
51
|
|
|
51
52
|
## 📦 Install
|
|
52
53
|
|
|
54
|
+
From npm (published to the official registry):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
dsh plugin --profile desktop add @having5548/dsh-downloader
|
|
58
|
+
```
|
|
59
|
+
|
|
53
60
|
From the GitHub release:
|
|
54
61
|
|
|
55
62
|
```bash
|
|
56
|
-
dsh plugin --profile desktop add https://github.com/having5548/dsh-downloader/releases/latest/download/having5548-dsh-downloader-0.
|
|
63
|
+
dsh plugin --profile desktop add https://github.com/having5548/dsh-downloader/releases/latest/download/having5548-dsh-downloader-0.6.0.tgz
|
|
57
64
|
```
|
|
58
65
|
|
|
59
66
|
Or build and install locally:
|
|
60
67
|
|
|
61
68
|
```bash
|
|
62
69
|
npm pack
|
|
63
|
-
dsh plugin --profile desktop add having5548-dsh-downloader-0.
|
|
70
|
+
dsh plugin --profile desktop add having5548-dsh-downloader-0.6.0.tgz
|
|
64
71
|
```
|
|
65
72
|
|
|
66
73
|
> Profile name: the current Electron desktop app uses `desktop`; the older web CLI used `web`.
|
|
74
|
+
> The README shown on the npm page is the one captured **at publish time** — later documentation edits only reach it with the next release.
|
|
67
75
|
|
|
68
76
|
Then open **Settings → Download proxy**.
|
|
69
77
|
|
|
@@ -257,7 +265,7 @@ The upstream is resolved in this order; the first hit wins:
|
|
|
257
265
|
| `downloadDir` | empty | empty = `$DSH_HOME/downloads` |
|
|
258
266
|
| `maxDownloadMb` | `512` | per-file size limit |
|
|
259
267
|
| `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 |
|
|
268
|
+
| `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
269
|
| `stallTimeoutS` | `30` | abort after this long with no data |
|
|
262
270
|
| `insecureTls` | `false` | skip TLS verification (corporate TLS interception only) |
|
|
263
271
|
| `allowOutsideWorkspace` | `false` | allow `save_path` outside the workspace and download directory |
|
|
@@ -351,7 +359,7 @@ The core (rule engine, CN data, loopback mixed proxy, node transports, subscript
|
|
|
351
359
|
|
|
352
360
|
```bash
|
|
353
361
|
npm install
|
|
354
|
-
node test/smoke.mjs #
|
|
362
|
+
node test/smoke.mjs # 406 checks: rules / subscriptions / profile import / routing / HTTP client / end-to-end / session guard / token gate
|
|
355
363
|
node --check lib/client.js
|
|
356
364
|
npm pack
|
|
357
365
|
```
|
package/README.md
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
简体中文 | [English](README.en.md)
|
|
8
8
|
|
|
9
|
-

|
|
10
|
+

|
|
10
11
|

|
|
11
12
|

|
|
12
|
-

|
|
13
14
|

|
|
14
15
|
|
|
15
16
|
</div>
|
|
@@ -50,20 +51,27 @@ NO_PROXY=localhost,127.0.0.1,::1,deepseek.com,.deepseek.com
|
|
|
50
51
|
|
|
51
52
|
## 📦 安装
|
|
52
53
|
|
|
54
|
+
从 npm 安装(已发布到官方源):
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
dsh plugin --profile desktop add @having5548/dsh-downloader
|
|
58
|
+
```
|
|
59
|
+
|
|
53
60
|
从 GitHub Release 安装:
|
|
54
61
|
|
|
55
62
|
```bash
|
|
56
|
-
dsh plugin --profile desktop add https://github.com/having5548/dsh-downloader/releases/latest/download/having5548-dsh-downloader-0.
|
|
63
|
+
dsh plugin --profile desktop add https://github.com/having5548/dsh-downloader/releases/latest/download/having5548-dsh-downloader-0.6.0.tgz
|
|
57
64
|
```
|
|
58
65
|
|
|
59
66
|
本地打包安装:
|
|
60
67
|
|
|
61
68
|
```bash
|
|
62
69
|
npm pack
|
|
63
|
-
dsh plugin --profile desktop add having5548-dsh-downloader-0.
|
|
70
|
+
dsh plugin --profile desktop add having5548-dsh-downloader-0.6.0.tgz
|
|
64
71
|
```
|
|
65
72
|
|
|
66
73
|
> profile 名:新版桌面端是 `desktop`,旧版 Web 端是 `web`。
|
|
74
|
+
> npm 页面上显示的 README 是**发布那一刻**的版本 —— 后来改的文档要等下一次发版才会同步过去。
|
|
67
75
|
|
|
68
76
|
装完打开 **设置 → 下载代理**。
|
|
69
77
|
|
|
@@ -356,7 +364,7 @@ dsh_run_proxied({ command: "git clone https://github.com/x/y.git" })
|
|
|
356
364
|
|
|
357
365
|
```bash
|
|
358
366
|
npm install
|
|
359
|
-
node test/smoke.mjs #
|
|
367
|
+
node test/smoke.mjs # 406 项:规则引擎 / 订阅解析 / 配置导入 / 路由 / HTTP 客户端 / 端到端下载 / 会话守卫 / 令牌鉴权
|
|
360
368
|
node --check lib/client.js
|
|
361
369
|
npm pack
|
|
362
370
|
```
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* [dsh-downloader] 把内核开放给 harness 自身的出网。
|
|
3
|
+
*
|
|
4
|
+
* 默认情况下内核只用「本进程随机令牌」保护,只服务本插件自己的下载。开启
|
|
5
|
+
* `harnessProxy` 之后:
|
|
6
|
+
* 1. 内核绑一个**固定端口**(`.env` 里要写死它,随机端口没法指);
|
|
7
|
+
* 2. 额外接受一个**持久化令牌**(重启不变,否则每次重启都 407);
|
|
8
|
+
* 3. 把 `HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` 写进 `$DSH_HOME/.env`,
|
|
9
|
+
* DSH 启动时会据此装全局 dispatcher,并把变量传给子进程。
|
|
10
|
+
*
|
|
11
|
+
* 覆盖范围:`web_fetch`、shell 子进程(curl/git/npm)、MCP 的 HTTP 传输、
|
|
12
|
+
* 以及 jobs/workflow/subagent 派生的进程。
|
|
13
|
+
* **覆盖不到**:Electron 渲染进程/内嵌 webview —— Chromium 读的是**系统代理**,
|
|
14
|
+
* 不读环境变量。
|
|
15
|
+
*
|
|
16
|
+
* 本模块只碰自己那 6 个 `*_PROXY` 变量与 `NO_PROXY`,其它行一律不动;写前备份。
|
|
17
|
+
*/
|
|
18
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
import { LOOPBACK_NO_PROXY, composeNoProxy, readNoProxy, splitEntries } from "./session-guard.js";
|
|
22
|
+
|
|
23
|
+
/** 内核固定端口的默认值。 */
|
|
24
|
+
export const DEFAULT_HARNESS_PORT = 17890;
|
|
25
|
+
|
|
26
|
+
/** 持久化令牌的文件名(放在插件数据目录里)。 */
|
|
27
|
+
export const HARNESS_TOKEN_FILE = "harness-token.txt";
|
|
28
|
+
|
|
29
|
+
/** 我们会写入/清除的 `.env` 键。大小写各一份,兼容只认其中一种的程序。 */
|
|
30
|
+
export const HARNESS_ENV_KEYS = [
|
|
31
|
+
"HTTP_PROXY",
|
|
32
|
+
"HTTPS_PROXY",
|
|
33
|
+
"ALL_PROXY",
|
|
34
|
+
"http_proxy",
|
|
35
|
+
"https_proxy",
|
|
36
|
+
"all_proxy"
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function tokenPath(dataDir) {
|
|
40
|
+
return join(dataDir, HARNESS_TOKEN_FILE);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 读取持久化令牌;不存在时生成一个。
|
|
45
|
+
* @param dataDir - 插件数据目录。
|
|
46
|
+
* @param create - false 时只读,不存在返回 null(用于 `status` 不改磁盘)。
|
|
47
|
+
*/
|
|
48
|
+
export function loadHarnessToken(dataDir, { create = true } = {}) {
|
|
49
|
+
const file = tokenPath(dataDir);
|
|
50
|
+
try {
|
|
51
|
+
const raw = readFileSync(file, "utf8").trim();
|
|
52
|
+
if (/^[0-9a-f]{32,}$/i.test(raw)) return raw;
|
|
53
|
+
} catch {
|
|
54
|
+
// 不存在或读不了 —— 下面按需生成。
|
|
55
|
+
}
|
|
56
|
+
if (!create) return null;
|
|
57
|
+
const token = randomBytes(24).toString("hex");
|
|
58
|
+
mkdirSync(dataDir, { recursive: true });
|
|
59
|
+
writeFileSync(file, `${token}\n`, { encoding: "utf8", mode: 0o600 });
|
|
60
|
+
return token;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 令牌文件路径,供调用方展示。 */
|
|
64
|
+
export function harnessTokenFile(dataDir) {
|
|
65
|
+
return tokenPath(dataDir);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 删掉持久化令牌(关闭开放时可选)。 */
|
|
69
|
+
export function deleteHarnessToken(dataDir) {
|
|
70
|
+
try {
|
|
71
|
+
unlinkSync(tokenPath(dataDir));
|
|
72
|
+
return true;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 拼出写进 `.env` 的代理地址:`http://dsh:<令牌>@127.0.0.1:<端口>`。 */
|
|
79
|
+
export function buildHarnessProxyUrl({ port, token }) {
|
|
80
|
+
if (!Number.isInteger(port) || port <= 0) throw new Error("端口无效");
|
|
81
|
+
if (typeof token !== "string" || token.length === 0) throw new Error("令牌无效");
|
|
82
|
+
return `http://dsh:${token}@127.0.0.1:${port}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 从 `.env` 正文里读出现有的 `HTTP_PROXY` 值(没有则 null)。 */
|
|
86
|
+
export function readHarnessProxyUrl(content) {
|
|
87
|
+
for (const line of String(content).split(/\r?\n/)) {
|
|
88
|
+
const match = /^\s*http_proxy\s*=\s*(.*)$/i.exec(line);
|
|
89
|
+
if (match !== null) return match[1].trim() || null;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `.env` 里是否已经有我们写的那几个 `*_PROXY` 键。 */
|
|
95
|
+
export function hasHarnessProxyEnv(content) {
|
|
96
|
+
const keys = new Set(HARNESS_ENV_KEYS.map((key) => key.toLowerCase()));
|
|
97
|
+
for (const line of String(content).split(/\r?\n/)) {
|
|
98
|
+
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
99
|
+
if (match !== null && keys.has(match[1].toLowerCase())) return true;
|
|
100
|
+
}
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 把 6 个 `*_PROXY` 行摘掉(保留其它所有行)。 */
|
|
105
|
+
export function stripHarnessProxyEnv(content) {
|
|
106
|
+
const keys = new Set(HARNESS_ENV_KEYS.map((key) => key.toLowerCase()));
|
|
107
|
+
const kept = String(content)
|
|
108
|
+
.split(/\r?\n/)
|
|
109
|
+
.filter((line) => {
|
|
110
|
+
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
111
|
+
if (match === null) return true;
|
|
112
|
+
return !keys.has(match[1].toLowerCase());
|
|
113
|
+
});
|
|
114
|
+
return kept.join("\n").replace(/\n{3,}/g, "\n\n");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 写入或替换 6 个 `*_PROXY` 行。 */
|
|
118
|
+
export function upsertHarnessProxyEnv(content, url) {
|
|
119
|
+
const keys = new Set(HARNESS_ENV_KEYS.map((key) => key.toLowerCase()));
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
const lines = [];
|
|
122
|
+
for (const line of String(content).split(/\r?\n/)) {
|
|
123
|
+
const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line);
|
|
124
|
+
if (match !== null && keys.has(match[1].toLowerCase())) {
|
|
125
|
+
const key = HARNESS_ENV_KEYS.find((candidate) => candidate.toLowerCase() === match[1].toLowerCase());
|
|
126
|
+
if (seen.has(key)) continue;
|
|
127
|
+
seen.add(key);
|
|
128
|
+
lines.push(`${key}=${url}`);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
lines.push(line);
|
|
132
|
+
}
|
|
133
|
+
for (const key of HARNESS_ENV_KEYS) {
|
|
134
|
+
if (!seen.has(key)) lines.push(`${key}=${url}`);
|
|
135
|
+
}
|
|
136
|
+
return lines.join("\n");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function backup(envPath, stamp) {
|
|
140
|
+
const suffix = stamp ?? new Date().toISOString().replace(/[:.]/g, "-");
|
|
141
|
+
const backupPath = `${envPath}.bak-${suffix}`;
|
|
142
|
+
copyFileSync(envPath, backupPath);
|
|
143
|
+
return backupPath;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 把内核地址写进 `$DSH_HOME/.env`。
|
|
148
|
+
*
|
|
149
|
+
* `NO_PROXY` 会被补上**回环 + 国内 AI 平台域名**:前者避免「代理指向自己」的死循环,
|
|
150
|
+
* 后者保证代理抖动时模型长连接不走代理(否则会话会断)。
|
|
151
|
+
*/
|
|
152
|
+
export function writeHarnessProxyEnv({ envPath, url, directDomains = [], stamp }) {
|
|
153
|
+
const exists = existsSync(envPath);
|
|
154
|
+
const previous = exists ? readFileSync(envPath, "utf8") : "";
|
|
155
|
+
let backupPath = null;
|
|
156
|
+
if (exists) backupPath = backup(envPath, stamp);
|
|
157
|
+
let next = upsertHarnessProxyEnv(previous, url);
|
|
158
|
+
const noProxyDomains = [...LOOPBACK_NO_PROXY, ...directDomains];
|
|
159
|
+
const noProxy = composeNoProxy(readNoProxy(next), noProxyDomains);
|
|
160
|
+
const withoutNoProxy = next
|
|
161
|
+
.split(/\r?\n/)
|
|
162
|
+
.filter((line) => !/^\s*no_proxy\s*=/i.test(line));
|
|
163
|
+
next = `${withoutNoProxy.join("\n").replace(/\n+$/, "")}\nNO_PROXY=${noProxy}\nno_proxy=${noProxy}\n`;
|
|
164
|
+
writeFileSync(envPath, next, "utf8");
|
|
165
|
+
return { changed: true, envPath, backupPath, url, noProxy, previous };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** 从 `.env` 里撤掉我们写的东西(`NO_PROXY` 保留,它只影响不走代理的域名)。 */
|
|
169
|
+
export function clearHarnessProxyEnv({ envPath, stamp }) {
|
|
170
|
+
if (!existsSync(envPath)) return { changed: false, envPath, backupPath: null };
|
|
171
|
+
const previous = readFileSync(envPath, "utf8");
|
|
172
|
+
if (!hasHarnessProxyEnv(previous)) return { changed: false, envPath, backupPath: null, previous };
|
|
173
|
+
const backupPath = backup(envPath, stamp);
|
|
174
|
+
writeFileSync(envPath, stripHarnessProxyEnv(previous), "utf8");
|
|
175
|
+
return { changed: true, envPath, backupPath, previous };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** 只读检查:`.env` 现在指向哪、和期望值是否一致。 */
|
|
179
|
+
export function inspectHarnessProxy({ envPath, expectedUrl }) {
|
|
180
|
+
const exists = existsSync(envPath);
|
|
181
|
+
const content = exists ? readFileSync(envPath, "utf8") : "";
|
|
182
|
+
const current = readHarnessProxyUrl(content);
|
|
183
|
+
return {
|
|
184
|
+
envPath,
|
|
185
|
+
exists,
|
|
186
|
+
current,
|
|
187
|
+
expected: expectedUrl ?? null,
|
|
188
|
+
matches: expectedUrl === null ? current !== null : current === expectedUrl,
|
|
189
|
+
hasProxyVars: hasHarnessProxyEnv(content),
|
|
190
|
+
noProxy: readNoProxy(content)
|
|
191
|
+
};
|
|
192
|
+
}
|
package/lib/core/proxy-server.js
CHANGED
|
@@ -51,7 +51,7 @@ function pipeSockets(source, target, counter) {
|
|
|
51
51
|
export class ProxyServer {
|
|
52
52
|
#engine;
|
|
53
53
|
#resolveNode;
|
|
54
|
-
#
|
|
54
|
+
#tokens = new Set();
|
|
55
55
|
#server = null;
|
|
56
56
|
#port = 0;
|
|
57
57
|
/** @type {{up: number, down: number}} */
|
|
@@ -65,15 +65,19 @@ export class ProxyServer {
|
|
|
65
65
|
* that present it are served; every other connection is refused, so nothing
|
|
66
66
|
* outside the owning plugin can borrow this listener as a proxy.
|
|
67
67
|
*/
|
|
68
|
-
constructor({ engine, resolveNode, token = null }) {
|
|
68
|
+
constructor({ engine, resolveNode, token = null, tokens = [] }) {
|
|
69
69
|
this.#engine = engine;
|
|
70
70
|
this.#resolveNode = resolveNode;
|
|
71
|
-
|
|
71
|
+
// [dsh-downloader] 接受**多个**令牌:本进程的随机令牌(本插件自己的下载)
|
|
72
|
+
// 与可选的持久化令牌(`harnessProxy` 开启后给 harness 自身出网用)。
|
|
73
|
+
for (const candidate of [token, ...tokens]) {
|
|
74
|
+
if (typeof candidate === "string" && candidate.length > 0) this.#tokens.add(candidate);
|
|
75
|
+
}
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
/** Whether this listener is token-gated. */
|
|
75
79
|
get gated() {
|
|
76
|
-
return this.#
|
|
80
|
+
return this.#tokens.size > 0;
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
/** Bound loopback port (0 until started). */
|
|
@@ -87,12 +91,20 @@ export class ProxyServer {
|
|
|
87
91
|
* `Basic dsh:<token>` form (what `curl -x dsh:<token>@…` would send) count.
|
|
88
92
|
*/
|
|
89
93
|
#authorized(headers) {
|
|
90
|
-
if (this.#
|
|
94
|
+
if (this.#tokens.size === 0) return true;
|
|
91
95
|
const raw = headers["proxy-authorization"];
|
|
92
96
|
if (typeof raw !== "string" || raw.length === 0) return false;
|
|
93
|
-
if (raw
|
|
94
|
-
|
|
95
|
-
|
|
97
|
+
if (raw.startsWith("Bearer ")) {
|
|
98
|
+
return this.#tokens.has(raw.slice("Bearer ".length));
|
|
99
|
+
}
|
|
100
|
+
if (raw.startsWith("Basic ")) {
|
|
101
|
+
const decoded = Buffer.from(raw.slice("Basic ".length), "base64").toString("utf8");
|
|
102
|
+
// 形如 `dsh:<令牌>`
|
|
103
|
+
const colon = decoded.indexOf(":");
|
|
104
|
+
if (colon < 0) return false;
|
|
105
|
+
return this.#tokens.has(decoded.slice(colon + 1));
|
|
106
|
+
}
|
|
107
|
+
return false;
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
/** Start listening on 127.0.0.1. @returns the bound port. */
|
|
@@ -122,7 +134,7 @@ export class ProxyServer {
|
|
|
122
134
|
// [dsh-downloader] A SOCKS5 greeting cannot carry the token, so a
|
|
123
135
|
// gated listener refuses the protocol outright instead of serving
|
|
124
136
|
// an unauthenticated caller. This plugin's own client uses CONNECT.
|
|
125
|
-
if (this.#
|
|
137
|
+
if (this.#tokens.size > 0) {
|
|
126
138
|
client.end(Buffer.from([0x05, 0xff]));
|
|
127
139
|
return;
|
|
128
140
|
}
|
|
@@ -151,7 +163,14 @@ export class ProxyServer {
|
|
|
151
163
|
}).catch(() => client.destroy());
|
|
152
164
|
client.on("data", (chunk) => {
|
|
153
165
|
this.counters.up += chunk.length;
|
|
154
|
-
|
|
166
|
+
// [dsh-downloader] 上游可能已经断了(连接器进程退出、socket 被销毁)。
|
|
167
|
+
// 写入必须兜住:让它抛出去就是未捕获异常,会崩掉整个宿主进程。
|
|
168
|
+
try {
|
|
169
|
+
face.write(chunk);
|
|
170
|
+
} catch {
|
|
171
|
+
try { face.close(); } catch { /* already gone */ }
|
|
172
|
+
client.destroy();
|
|
173
|
+
}
|
|
155
174
|
});
|
|
156
175
|
client.on("end", () => face.close());
|
|
157
176
|
client.on("close", () => face.close());
|
package/lib/core/transports.js
CHANGED
|
@@ -552,6 +552,60 @@ export function hasNativeConnector() {
|
|
|
552
552
|
try { return existsSync(CONNECTOR_PATH); } catch { return false; }
|
|
553
553
|
}
|
|
554
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Wrap a spawned connector's stdio as a duplex face.
|
|
557
|
+
*
|
|
558
|
+
* [dsh-downloader] 这三根管道**必须**在第一时间挂上 `error` 监听。连接器进程一死,
|
|
559
|
+
* 管道就断了,之后任何一次 `write` 都会让 `child.stdin` 抛出 `EPIPE`;而在 Node 里
|
|
560
|
+
* **没有监听者的 `error` 事件是未捕获异常**,会直接把宿主进程带走 ——
|
|
561
|
+
* 实测把 DSH 宿主崩掉两次(crash-2026-10-11T03-46 / 03-48,栈就落在下面这行 write)。
|
|
562
|
+
*
|
|
563
|
+
* 另外 `ended` 标志挡不住这个竞态:`write` 可能排在一个已经断掉的管道上,
|
|
564
|
+
* 而 `exit` 事件还没派发。所以写入本身也要兜住。
|
|
565
|
+
*
|
|
566
|
+
* 导出只为测试:生产路径只有 `nativeConnect` 调用它。
|
|
567
|
+
*/
|
|
568
|
+
export function stdioFace(child) {
|
|
569
|
+
let ended = false;
|
|
570
|
+
let stderrTail = "";
|
|
571
|
+
const markEnded = () => {
|
|
572
|
+
ended = true;
|
|
573
|
+
};
|
|
574
|
+
|
|
575
|
+
child.stdin.on("error", markEnded);
|
|
576
|
+
child.stdout.on("error", markEnded);
|
|
577
|
+
child.stderr.on("error", markEnded);
|
|
578
|
+
child.on("error", markEnded);
|
|
579
|
+
child.on("exit", markEnded);
|
|
580
|
+
child.stderr.on("data", (chunk) => {
|
|
581
|
+
stderrTail = (stderrTail + chunk.toString()).slice(-512);
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
return {
|
|
585
|
+
read: (async function* () {
|
|
586
|
+
for await (const chunk of child.stdout) {
|
|
587
|
+
yield chunk;
|
|
588
|
+
}
|
|
589
|
+
})(),
|
|
590
|
+
write: (data) => {
|
|
591
|
+
if (ended) return;
|
|
592
|
+
try {
|
|
593
|
+
child.stdin.write(data);
|
|
594
|
+
} catch {
|
|
595
|
+
// 管道已断(连接器已退出):丢掉这一块,绝不让它冒泡成未捕获异常。
|
|
596
|
+
ended = true;
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
close: () => {
|
|
600
|
+
try { child.stdin.end(); } catch { /* already closed */ }
|
|
601
|
+
try { child.stdout.destroy(); } catch { /* already closed */ }
|
|
602
|
+
if (!ended) setTimeout(() => child.kill(), 500);
|
|
603
|
+
},
|
|
604
|
+
/** 连接器最后的 stderr,排查「节点为什么连不上」时有用。 */
|
|
605
|
+
stderrTail: () => stderrTail
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
555
609
|
/**
|
|
556
610
|
* Connect through the self-compiled Go connector via stdio: stdin feeds the
|
|
557
611
|
* remote target, stdout carries the reply. Supports `hysteria2` and `reality`.
|
|
@@ -581,28 +635,7 @@ function nativeConnect(proto, node, host, port) {
|
|
|
581
635
|
stdio: ["pipe", "pipe", "pipe"],
|
|
582
636
|
windowsHide: true
|
|
583
637
|
});
|
|
584
|
-
|
|
585
|
-
let stderrTail = "";
|
|
586
|
-
child.stderr.on("data", (chunk) => {
|
|
587
|
-
stderrTail = (stderrTail + chunk.toString()).slice(-512);
|
|
588
|
-
});
|
|
589
|
-
child.on("exit", () => { ended = true; });
|
|
590
|
-
|
|
591
|
-
return {
|
|
592
|
-
read: (async function* () {
|
|
593
|
-
for await (const chunk of child.stdout) {
|
|
594
|
-
yield chunk;
|
|
595
|
-
}
|
|
596
|
-
})(),
|
|
597
|
-
write: (data) => {
|
|
598
|
-
if (!ended) child.stdin.write(data);
|
|
599
|
-
},
|
|
600
|
-
close: () => {
|
|
601
|
-
try { child.stdin.end(); } catch { /* already closed */ }
|
|
602
|
-
try { child.stdout.destroy(); } catch { /* already closed */ }
|
|
603
|
-
if (!ended) setTimeout(() => child.kill(), 500);
|
|
604
|
-
}
|
|
605
|
-
};
|
|
638
|
+
return stdioFace(child);
|
|
606
639
|
}
|
|
607
640
|
|
|
608
641
|
/**
|
package/lib/core/upstream.js
CHANGED
|
@@ -30,6 +30,7 @@ import { composeDirectDomains, directRuleLines } from "./ai-domains.js";
|
|
|
30
30
|
import { applyGuard, composeNoProxy, envFilePath, inspectGuard, restoreGuard } from "./session-guard.js";
|
|
31
31
|
import { ProfileStore, parseImportLink } from "./profiles.js";
|
|
32
32
|
import { buildProxyUrl } from "./shell-proxy.js";
|
|
33
|
+
import { DEFAULT_HARNESS_PORT, buildHarnessProxyUrl, loadHarnessToken } from "./harness-proxy.js";
|
|
33
34
|
|
|
34
35
|
/** Node types the vendored transports implement. */
|
|
35
36
|
const SUPPORTED_TYPES = new Set(["http", "socks5", "socks", "ss", "trojan", "vless", "vmess", "hysteria2"]);
|
|
@@ -688,11 +689,46 @@ export class UpstreamManager {
|
|
|
688
689
|
this.#state = "no-subscription";
|
|
689
690
|
}
|
|
690
691
|
|
|
692
|
+
/**
|
|
693
|
+
* [dsh-downloader] `harnessProxy` 开启时的端口与持久化令牌,否则 null。
|
|
694
|
+
* 令牌必须持久化:`.env` 里要写死它,随机令牌每次重启都会失效。
|
|
695
|
+
*/
|
|
696
|
+
#harnessOptions(config) {
|
|
697
|
+
if (config?.harnessProxy !== true) return null;
|
|
698
|
+
const token = loadHarnessToken(this.dataDir);
|
|
699
|
+
const port = Number.isInteger(config.harnessProxyPort) && config.harnessProxyPort > 0
|
|
700
|
+
? config.harnessProxyPort
|
|
701
|
+
: DEFAULT_HARNESS_PORT;
|
|
702
|
+
return { token, port, url: buildHarnessProxyUrl({ port, token }) };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** 绑固定端口;被占用时退回随机端口(此时 harnessProxy 实际不生效)。 */
|
|
706
|
+
async #listenCore(harness) {
|
|
707
|
+
if (harness === null) return await this.#server.start(0);
|
|
708
|
+
try {
|
|
709
|
+
return await this.#server.start(harness.port);
|
|
710
|
+
} catch (error) {
|
|
711
|
+
if (error?.code !== "EADDRINUSE") throw error;
|
|
712
|
+
this.#ctx.logger?.warn?.(
|
|
713
|
+
"dsh-downloader: 固定端口 %d 被占用,退回随机端口 —— harnessProxy 这一次不生效",
|
|
714
|
+
harness.port
|
|
715
|
+
);
|
|
716
|
+
return await this.#server.start(0);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
691
720
|
/** Start the loopback rule core and its timers. */
|
|
692
721
|
async #startCore() {
|
|
722
|
+
const config = this.#resolveConfig();
|
|
723
|
+
const harness = this.#harnessOptions(config);
|
|
693
724
|
try {
|
|
694
|
-
this.#server = new ProxyServer({
|
|
695
|
-
|
|
725
|
+
this.#server = new ProxyServer({
|
|
726
|
+
engine: this.#engine,
|
|
727
|
+
resolveNode: () => this.#currentNode(),
|
|
728
|
+
token: this.#scopeToken,
|
|
729
|
+
tokens: harness === null ? [] : [harness.token]
|
|
730
|
+
});
|
|
731
|
+
this.#port = await this.#listenCore(harness);
|
|
696
732
|
this.#state = "running";
|
|
697
733
|
this.#scheduleTimers(this.#resolveConfig());
|
|
698
734
|
this.#ctx.logger?.info?.("dsh-downloader: rule core listening on 127.0.0.1:%d (%s)", this.#port, this.#mode);
|
package/lib/index.js
CHANGED
|
@@ -23,6 +23,14 @@ import { homedir } from "node:os";
|
|
|
23
23
|
import { UpstreamManager } from "./core/upstream.js";
|
|
24
24
|
import { AI_DIRECT_DOMAINS, composeDirectDomains, normalizeDomain } from "./core/ai-domains.js";
|
|
25
25
|
import { applyGuard, envFilePath, inspectGuard, restoreGuard } from "./core/session-guard.js";
|
|
26
|
+
import {
|
|
27
|
+
DEFAULT_HARNESS_PORT,
|
|
28
|
+
buildHarnessProxyUrl,
|
|
29
|
+
clearHarnessProxyEnv,
|
|
30
|
+
inspectHarnessProxy,
|
|
31
|
+
loadHarnessToken,
|
|
32
|
+
writeHarnessProxyEnv
|
|
33
|
+
} from "./core/harness-proxy.js";
|
|
26
34
|
import { buildShellPrefix, buildShellToolArguments, classifyShellCommand, explainShellRouting } from "./core/shell-proxy.js";
|
|
27
35
|
import { resolveAuth, sanitizeHeaders } from "./download/auth.js";
|
|
28
36
|
import { decideRoute, NoUpstreamError } from "./download/route.js";
|
|
@@ -105,6 +113,8 @@ const Config = z.object({
|
|
|
105
113
|
threads: z.number().min(1).max(32).default(4).description("多线程下载的分片连接数;1 = 单连接。服务端不支持 Range 时自动回退单连接").volatile(),
|
|
106
114
|
insecureTls: z.boolean().default(false).description("跳过 TLS 证书校验(仅企业 TLS 解密环境需要)").volatile(),
|
|
107
115
|
allowOutsideWorkspace: z.boolean().default(false).description("允许 save_path 写到工作区与配置的下载目录之外").volatile(),
|
|
116
|
+
harnessProxy: z.boolean().default(false).description("把内核开放给 harness 自身的出网(web_fetch / shell 子进程 / MCP 的 HTTP)。用 dsh_harness_proxy 写入 .env 后重启生效").volatile(),
|
|
117
|
+
harnessProxyPort: z.number().step(1).min(1024).max(65535).default(17890).description("harness 出网用的固定端口 —— .env 里要写死,所以不能随机;被占用时自动退回随机端口(此时开放不生效)").volatile(),
|
|
108
118
|
maxRedirects: z.number().step(1).min(0).max(30).default(10).description("最多跟随多少次重定向").volatile()
|
|
109
119
|
});
|
|
110
120
|
|
|
@@ -752,6 +762,84 @@ function apply(ctx, config) {
|
|
|
752
762
|
})
|
|
753
763
|
);
|
|
754
764
|
|
|
765
|
+
// ---- dsh_harness_proxy -------------------------------------------------
|
|
766
|
+
disposers.push(
|
|
767
|
+
ctx.tools.register({
|
|
768
|
+
name: "dsh_harness_proxy",
|
|
769
|
+
description:
|
|
770
|
+
"让 harness 自身的出网也走本插件的代理内核(而不只是 dsh_download)。" +
|
|
771
|
+
"开启后覆盖 web_fetch、shell 子进程里的 curl/git/npm、MCP 的 HTTP 传输、以及 jobs/workflow/subagent 派生的进程;" +
|
|
772
|
+
"**覆盖不到** Electron 渲染进程与内嵌 webview(Chromium 读系统代理,不读环境变量)。" +
|
|
773
|
+
"做法是把内核绑到固定端口、加一个持久化令牌,再写进 $DSH_HOME/.env 的 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY(写前备份)," +
|
|
774
|
+
"所以**写入后要重启 DSH 才生效**。国内 AI 平台域名会自动进 NO_PROXY,保证会话不被代理抖动带断。" +
|
|
775
|
+
"status 只读;apply 写入;remove 撤掉。",
|
|
776
|
+
parameters: {
|
|
777
|
+
type: "object",
|
|
778
|
+
properties: {
|
|
779
|
+
action: { type: "string", enum: ["status", "apply", "remove"], description: "status(默认)只读;apply 写 .env(需重启);remove 撤掉那几行。" }
|
|
780
|
+
},
|
|
781
|
+
additionalProperties: false
|
|
782
|
+
},
|
|
783
|
+
output: { schema: OBJECT_OUTPUT_SCHEMA, render: (_args, value) => renderHarnessProxy(value) },
|
|
784
|
+
isConcurrencySafe: () => true,
|
|
785
|
+
presentCall: (args) => ({ card: "generic", kind: "other", title: `harness 出网代理:${args?.action ?? "status"}` }),
|
|
786
|
+
presentResult: () => ({ card: "generic", title: "harness 出网代理" }),
|
|
787
|
+
execute: async (args) => {
|
|
788
|
+
const section = resolveConfig();
|
|
789
|
+
const requested = ["status", "apply", "remove"].includes(String(args?.action ?? "status")) ? String(args?.action ?? "status") : "status";
|
|
790
|
+
const enabled = section.harnessProxy === true;
|
|
791
|
+
const port = Number.isInteger(section.harnessProxyPort) ? section.harnessProxyPort : DEFAULT_HARNESS_PORT;
|
|
792
|
+
const envPath = envFilePath(process.env.DSH_HOME);
|
|
793
|
+
const dataDir = upstream.dataDir;
|
|
794
|
+
const token = loadHarnessToken(dataDir, { create: requested === "apply" });
|
|
795
|
+
const expectedUrl = token === null ? null : buildHarnessProxyUrl({ port, token });
|
|
796
|
+
const domains = composeDirectDomains({
|
|
797
|
+
protectAiPlatforms: section.protectAiPlatforms !== false,
|
|
798
|
+
extraDirectDomains: section.extraDirectDomains ?? []
|
|
799
|
+
});
|
|
800
|
+
const notes = [];
|
|
801
|
+
let receipt = null;
|
|
802
|
+
|
|
803
|
+
if (requested === "apply") {
|
|
804
|
+
if (!enabled) notes.push("提示:开关 harnessProxy 还没打开 —— 先到设置里打开它,否则内核不会绑这个固定端口,写进去的地址会连不上。");
|
|
805
|
+
receipt = writeHarnessProxyEnv({ envPath, url: expectedUrl, directDomains: domains });
|
|
806
|
+
notes.push("已写入。DSH 只在启动时读一次 $DSH_HOME/.env,**重启 DSH 后生效**。");
|
|
807
|
+
} else if (requested === "remove") {
|
|
808
|
+
receipt = clearHarnessProxyEnv({ envPath });
|
|
809
|
+
if (receipt.changed === true) notes.push("已撤掉代理变量,重启 DSH 后恢复直连。");
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
const state = inspectHarnessProxy({ envPath, expectedUrl });
|
|
813
|
+
if (requested === "status") {
|
|
814
|
+
if (!enabled) notes.push("harnessProxy 当前关闭:内核只用随机端口 + 随机令牌,只服务本插件自己的下载。");
|
|
815
|
+
else if (state.current === null) notes.push('已开启但 .env 里还没有代理变量 —— 用 action="apply" 写入,然后重启。');
|
|
816
|
+
else if (!state.matches) notes.push("已开启,但 .env 里的地址与当前端口/令牌不一致(换过端口或令牌文件被删)—— 重新 apply 一次。");
|
|
817
|
+
else notes.push("已开启且 .env 指向当前内核。改过隧道/端口后记得重新 apply。");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
return {
|
|
821
|
+
action: requested,
|
|
822
|
+
enabled,
|
|
823
|
+
port,
|
|
824
|
+
core_port: upstream.status().core_port ?? null,
|
|
825
|
+
core_matches_port: (upstream.status().core_port ?? null) === port,
|
|
826
|
+
env_path: envPath,
|
|
827
|
+
env_current: state.current,
|
|
828
|
+
env_expected: expectedUrl,
|
|
829
|
+
env_matches: state.matches,
|
|
830
|
+
has_proxy_vars: state.hasProxyVars,
|
|
831
|
+
no_proxy_count: state.noProxy.length === 0 ? 0 : state.noProxy.split(",").filter((entry) => entry.length > 0).length,
|
|
832
|
+
protected_domain_count: domains.length,
|
|
833
|
+
changed: receipt === null ? null : receipt.changed,
|
|
834
|
+
backup_path: receipt === null ? null : receipt.backupPath,
|
|
835
|
+
covers: ["web_fetch", "shell 子进程(curl/git/npm)", "MCP 的 HTTP 传输", "jobs/workflow/subagent 派生的进程"],
|
|
836
|
+
does_not_cover: ["Electron 渲染进程与内嵌 webview(Chromium 读系统代理)", "系统里其它程序"],
|
|
837
|
+
notes
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
})
|
|
841
|
+
);
|
|
842
|
+
|
|
755
843
|
// ---- dsh_profile -------------------------------------------------------
|
|
756
844
|
disposers.push(
|
|
757
845
|
ctx.tools.register({
|
|
@@ -1029,6 +1117,27 @@ function apply(ctx, config) {
|
|
|
1029
1117
|
}
|
|
1030
1118
|
|
|
1031
1119
|
/** Render a session-guard report. */
|
|
1120
|
+
function renderHarnessProxy(value) {
|
|
1121
|
+
const lines = [
|
|
1122
|
+
`**harness 出网代理**:${value.enabled ? "已开启" : "已关闭"}`,
|
|
1123
|
+
value.enabled
|
|
1124
|
+
? `内核端口 ${value.port}(实际 ${value.core_port ?? "未运行"}${value.core_matches_port ? "" : " ⚠️ 与配置不一致"})`
|
|
1125
|
+
: `内核端口 ${value.core_port ?? "随机(未运行)"}`,
|
|
1126
|
+
value.env_current === null
|
|
1127
|
+
? `\`.env\`:还没有代理变量`
|
|
1128
|
+
: value.env_matches
|
|
1129
|
+
? `\`.env\`:指向当前内核 ✅`
|
|
1130
|
+
: `\`.env\`:与当前不一致 ⚠️`
|
|
1131
|
+
];
|
|
1132
|
+
if (value.changed !== null) {
|
|
1133
|
+
lines.push(`变更:${value.changed ? "已写入" : "无变化"}${value.backup_path ? `(备份 \`${value.backup_path}\`)` : ""}`);
|
|
1134
|
+
}
|
|
1135
|
+
lines.push("", "覆盖:" + value.covers.join("、"));
|
|
1136
|
+
lines.push("覆盖不到:" + value.does_not_cover.join("、"));
|
|
1137
|
+
for (const note of value.notes) lines.push("", `> ${note}`);
|
|
1138
|
+
return lines.join("\n");
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1032
1141
|
function renderGuard(value) {
|
|
1033
1142
|
if (value === null || typeof value !== "object") return text(value);
|
|
1034
1143
|
const lines = [
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@having5548/dsh-downloader",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "DeepSeek Harness 下载代理插件:注册 dsh_download 等工具,让模型下载境外文件时自动走插件自带的代理内核(订阅自包含,或显式 proxyUrl),境内直连;带 sha256、大小上限、停滞检测与直连失败自动回退,另附「设置 → 下载代理」管理面板。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -83,6 +83,7 @@
|
|
|
83
83
|
},
|
|
84
84
|
"license": "MIT",
|
|
85
85
|
"publishConfig": {
|
|
86
|
-
"access": "public"
|
|
86
|
+
"access": "public",
|
|
87
|
+
"registry": "https://registry.npmjs.org"
|
|
87
88
|
}
|
|
88
89
|
}
|