@having5548/dsh-downloader 0.5.3 → 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 +31 -1
- package/README.en.md +5 -5
- package/README.md +5 -5
- package/lib/core/harness-proxy.js +192 -0
- package/lib/core/proxy-server.js +21 -9
- package/lib/core/upstream.js +38 -2
- package/lib/index.js +109 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -9,8 +9,38 @@
|
|
|
9
9
|
|
|
10
10
|
## [未发布]
|
|
11
11
|
|
|
12
|
-
## [0.
|
|
12
|
+
## [0.6.0] - 2026-10-17
|
|
13
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
|
+
|
|
14
44
|
### 修复
|
|
15
45
|
|
|
16
46
|
- **上游断管会把整个 DSH 宿主崩掉**(严重,宿主连续崩了两次)。
|
package/README.en.md
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
|
|
7
7
|
[简体中文](README.md) | English
|
|
8
8
|
|
|
9
|
-

|
|
10
10
|

|
|
11
11
|

|
|
12
12
|

|
|
13
|
-

|
|
14
14
|

|
|
15
15
|
|
|
16
16
|
</div>
|
|
@@ -60,14 +60,14 @@ dsh plugin --profile desktop add @having5548/dsh-downloader
|
|
|
60
60
|
From the GitHub release:
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
|
-
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
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
Or build and install locally:
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
npm pack
|
|
70
|
-
dsh plugin --profile desktop add having5548-dsh-downloader-0.
|
|
70
|
+
dsh plugin --profile desktop add having5548-dsh-downloader-0.6.0.tgz
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
> Profile name: the current Electron desktop app uses `desktop`; the older web CLI used `web`.
|
|
@@ -359,7 +359,7 @@ The core (rule engine, CN data, loopback mixed proxy, node transports, subscript
|
|
|
359
359
|
|
|
360
360
|
```bash
|
|
361
361
|
npm install
|
|
362
|
-
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
|
|
363
363
|
node --check lib/client.js
|
|
364
364
|
npm pack
|
|
365
365
|
```
|
package/README.md
CHANGED
|
@@ -6,11 +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
16
|
</div>
|
|
@@ -60,14 +60,14 @@ dsh plugin --profile desktop add @having5548/dsh-downloader
|
|
|
60
60
|
从 GitHub Release 安装:
|
|
61
61
|
|
|
62
62
|
```bash
|
|
63
|
-
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
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
本地打包安装:
|
|
67
67
|
|
|
68
68
|
```bash
|
|
69
69
|
npm pack
|
|
70
|
-
dsh plugin --profile desktop add having5548-dsh-downloader-0.
|
|
70
|
+
dsh plugin --profile desktop add having5548-dsh-downloader-0.6.0.tgz
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
> profile 名:新版桌面端是 `desktop`,旧版 Web 端是 `web`。
|
|
@@ -364,7 +364,7 @@ dsh_run_proxied({ command: "git clone https://github.com/x/y.git" })
|
|
|
364
364
|
|
|
365
365
|
```bash
|
|
366
366
|
npm install
|
|
367
|
-
node test/smoke.mjs #
|
|
367
|
+
node test/smoke.mjs # 406 项:规则引擎 / 订阅解析 / 配置导入 / 路由 / HTTP 客户端 / 端到端下载 / 会话守卫 / 令牌鉴权
|
|
368
368
|
node --check lib/client.js
|
|
369
369
|
npm pack
|
|
370
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
|
}
|
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",
|