@having5548/dsh-downloader 0.5.3 → 0.7.1
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 +82 -1
- package/README.en.md +5 -5
- package/README.md +5 -5
- package/lib/client.js +1343 -650
- package/lib/core/harness-proxy.js +192 -0
- package/lib/core/proxy-server.js +21 -9
- package/lib/core/upstream.js +49 -2
- package/lib/index.js +109 -0
- package/package.json +5 -1
- package/scripts/fix-nested-links.mjs +130 -0
|
@@ -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"]);
|
|
@@ -276,6 +277,12 @@ export class UpstreamManager {
|
|
|
276
277
|
await this.#saveState();
|
|
277
278
|
}
|
|
278
279
|
|
|
280
|
+
/** [dsh-downloader] 回到「自动选择」:清掉钉死的节点,让分组策略重新挑。 */
|
|
281
|
+
async clearSelection() {
|
|
282
|
+
this.#selected = null;
|
|
283
|
+
await this.#saveState();
|
|
284
|
+
}
|
|
285
|
+
|
|
279
286
|
/** One latency test, for the panel. */
|
|
280
287
|
async testLatency(name) {
|
|
281
288
|
const node = this.#nodes.get(name);
|
|
@@ -418,6 +425,11 @@ export class UpstreamManager {
|
|
|
418
425
|
await this.selectNode(body.name);
|
|
419
426
|
return send(200, { ok: true, selected: body.name });
|
|
420
427
|
}
|
|
428
|
+
case "select-auto": {
|
|
429
|
+
if (method !== "POST") return send(405, { error: "该接口仅支持 POST" });
|
|
430
|
+
await this.clearSelection();
|
|
431
|
+
return send(200, { ok: true, selected: null });
|
|
432
|
+
}
|
|
421
433
|
case "traffic":
|
|
422
434
|
return send(200, this.#trafficSnapshot());
|
|
423
435
|
case "guard":
|
|
@@ -688,11 +700,46 @@ export class UpstreamManager {
|
|
|
688
700
|
this.#state = "no-subscription";
|
|
689
701
|
}
|
|
690
702
|
|
|
703
|
+
/**
|
|
704
|
+
* [dsh-downloader] `harnessProxy` 开启时的端口与持久化令牌,否则 null。
|
|
705
|
+
* 令牌必须持久化:`.env` 里要写死它,随机令牌每次重启都会失效。
|
|
706
|
+
*/
|
|
707
|
+
#harnessOptions(config) {
|
|
708
|
+
if (config?.harnessProxy !== true) return null;
|
|
709
|
+
const token = loadHarnessToken(this.dataDir);
|
|
710
|
+
const port = Number.isInteger(config.harnessProxyPort) && config.harnessProxyPort > 0
|
|
711
|
+
? config.harnessProxyPort
|
|
712
|
+
: DEFAULT_HARNESS_PORT;
|
|
713
|
+
return { token, port, url: buildHarnessProxyUrl({ port, token }) };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** 绑固定端口;被占用时退回随机端口(此时 harnessProxy 实际不生效)。 */
|
|
717
|
+
async #listenCore(harness) {
|
|
718
|
+
if (harness === null) return await this.#server.start(0);
|
|
719
|
+
try {
|
|
720
|
+
return await this.#server.start(harness.port);
|
|
721
|
+
} catch (error) {
|
|
722
|
+
if (error?.code !== "EADDRINUSE") throw error;
|
|
723
|
+
this.#ctx.logger?.warn?.(
|
|
724
|
+
"dsh-downloader: 固定端口 %d 被占用,退回随机端口 —— harnessProxy 这一次不生效",
|
|
725
|
+
harness.port
|
|
726
|
+
);
|
|
727
|
+
return await this.#server.start(0);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
691
731
|
/** Start the loopback rule core and its timers. */
|
|
692
732
|
async #startCore() {
|
|
733
|
+
const config = this.#resolveConfig();
|
|
734
|
+
const harness = this.#harnessOptions(config);
|
|
693
735
|
try {
|
|
694
|
-
this.#server = new ProxyServer({
|
|
695
|
-
|
|
736
|
+
this.#server = new ProxyServer({
|
|
737
|
+
engine: this.#engine,
|
|
738
|
+
resolveNode: () => this.#currentNode(),
|
|
739
|
+
token: this.#scopeToken,
|
|
740
|
+
tokens: harness === null ? [] : [harness.token]
|
|
741
|
+
});
|
|
742
|
+
this.#port = await this.#listenCore(harness);
|
|
696
743
|
this.#state = "running";
|
|
697
744
|
this.#scheduleTimers(this.#resolveConfig());
|
|
698
745
|
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.7.1",
|
|
4
4
|
"description": "DeepSeek Harness 下载代理插件:注册 dsh_download 等工具,让模型下载境外文件时自动走插件自带的代理内核(订阅自包含,或显式 proxyUrl),境内直连;带 sha256、大小上限、停滞检测与直连失败自动回退,另附「设置 → 下载代理」管理面板。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -22,6 +22,9 @@
|
|
|
22
22
|
},
|
|
23
23
|
"type": "module",
|
|
24
24
|
"main": "lib/index.js",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"fix-links": "node scripts/fix-nested-links.mjs"
|
|
27
|
+
},
|
|
25
28
|
"exports": {
|
|
26
29
|
".": {
|
|
27
30
|
"default": "./lib/index.js"
|
|
@@ -33,6 +36,7 @@
|
|
|
33
36
|
},
|
|
34
37
|
"files": [
|
|
35
38
|
"lib",
|
|
39
|
+
"scripts",
|
|
36
40
|
"native",
|
|
37
41
|
"cordis.patch.yml",
|
|
38
42
|
"README.md",
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 修复 profile 里被 pnpm 留下「空壳」的嵌套依赖目录。
|
|
4
|
+
*
|
|
5
|
+
* 背景:DSH 的插件加载器**按每个包自己声明的 dependencies 构造路径**
|
|
6
|
+
* (`<包>/node_modules/<依赖>/<main>`)并直接 open;而 pnpm 的扁平布局有时只
|
|
7
|
+
* 建出空目录、不填内容。于是会出现:
|
|
8
|
+
*
|
|
9
|
+
* ENOENT: open '...\node_modules\@having5548\dsh-downloader\node_modules\
|
|
10
|
+
* @deepseek-ai\schemastery\lib\index.mjs'
|
|
11
|
+
*
|
|
12
|
+
* 现象是插件「failed to import」、整条 entry 不激活(连带其它插件也报同一路径)。
|
|
13
|
+
*
|
|
14
|
+
* 本脚本把「缺的嵌套依赖」指回顶层的同名包(Windows 用 junction,其它平台用
|
|
15
|
+
* 符号链接)。可反复执行:已经正确的会跳过。
|
|
16
|
+
*
|
|
17
|
+
* 用法:
|
|
18
|
+
* node scripts/fix-nested-links.mjs # 修当前目录下的 node_modules
|
|
19
|
+
* node scripts/fix-nested-links.mjs <profileDir> # 修指定 profile
|
|
20
|
+
* node scripts/fix-nested-links.mjs --dry-run # 只看要做什么
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
23
|
+
import { dirname, join, resolve } from "node:path";
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
|
|
26
|
+
const args = process.argv.slice(2);
|
|
27
|
+
const dryRun = args.includes("--dry-run");
|
|
28
|
+
const profileDir = resolve(args.find((arg) => !arg.startsWith("--")) ?? process.cwd());
|
|
29
|
+
const modules = join(profileDir, "node_modules");
|
|
30
|
+
|
|
31
|
+
if (!existsSync(modules)) {
|
|
32
|
+
console.error(`找不到 ${modules} —— 参数应当是 profile 目录(含 node_modules 的那层)`);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 枚举 node_modules 下的包目录(含 @scope/name 形式)。 */
|
|
37
|
+
function listPackages() {
|
|
38
|
+
const found = [];
|
|
39
|
+
for (const entry of readdirSync(modules, { withFileTypes: true })) {
|
|
40
|
+
if (!entry.isDirectory()) continue;
|
|
41
|
+
if (entry.name === ".bin" || entry.name.startsWith(".")) continue;
|
|
42
|
+
if (entry.name.startsWith("@")) {
|
|
43
|
+
for (const inner of readdirSync(join(modules, entry.name), { withFileTypes: true })) {
|
|
44
|
+
if (inner.isDirectory()) found.push(`${entry.name}/${inner.name}`);
|
|
45
|
+
}
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
found.push(entry.name);
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readJson(file) {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function link(from, to) {
|
|
62
|
+
if (process.platform === "win32") {
|
|
63
|
+
execFileSync("cmd", ["/c", "mklink", "/J", from, to], { stdio: "pipe" });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
execFileSync("ln", ["-s", to, from], { stdio: "pipe" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const fixed = [];
|
|
70
|
+
const skipped = [];
|
|
71
|
+
|
|
72
|
+
for (const name of listPackages()) {
|
|
73
|
+
const pkgDir = join(modules, name);
|
|
74
|
+
const manifest = readJson(join(pkgDir, "package.json"));
|
|
75
|
+
if (manifest === null) continue;
|
|
76
|
+
const deps = Object.keys(manifest.dependencies ?? {});
|
|
77
|
+
if (deps.length === 0) continue;
|
|
78
|
+
|
|
79
|
+
for (const dep of deps) {
|
|
80
|
+
const nested = join(pkgDir, "node_modules", dep);
|
|
81
|
+
const hoisted = join(modules, dep);
|
|
82
|
+
// 已经有内容就不管;只有「不存在 / 空目录 / 断链」才修。
|
|
83
|
+
let needsFix = false;
|
|
84
|
+
if (!existsSync(nested)) {
|
|
85
|
+
needsFix = true;
|
|
86
|
+
} else {
|
|
87
|
+
try {
|
|
88
|
+
const stat = lstatSync(nested);
|
|
89
|
+
if (stat.isDirectory() && readdirSync(nested).length === 0) needsFix = true;
|
|
90
|
+
} catch {
|
|
91
|
+
needsFix = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!needsFix) continue;
|
|
95
|
+
if (!existsSync(hoisted)) {
|
|
96
|
+
skipped.push(`${dep}(顶层也没有)`);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (statSync(hoisted) === undefined) continue;
|
|
100
|
+
const parent = dirname(nested);
|
|
101
|
+
const relative = `${name} → ${dep}`;
|
|
102
|
+
if (dryRun) {
|
|
103
|
+
fixed.push(`[dry-run] ${relative}`);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
if (existsSync(nested)) {
|
|
108
|
+
// 空目录:先删掉再建链接
|
|
109
|
+
execFileSync(process.platform === "win32" ? "cmd" : "rm", process.platform === "win32" ? ["/c", "rmdir", nested] : ["-rf", nested], { stdio: "pipe" });
|
|
110
|
+
}
|
|
111
|
+
if (!existsSync(parent)) {
|
|
112
|
+
execFileSync(process.platform === "win32" ? "cmd" : "mkdir", process.platform === "win32" ? ["/c", "mkdir", parent] : ["-p", parent], { stdio: "pipe" });
|
|
113
|
+
}
|
|
114
|
+
link(nested, hoisted);
|
|
115
|
+
fixed.push(relative);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
skipped.push(`${relative} —— ${error instanceof Error ? error.message : String(error)}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`profile: ${profileDir}`);
|
|
123
|
+
console.log(`已修 ${fixed.length} 条${dryRun ? "(干跑)" : ""}`);
|
|
124
|
+
for (const line of fixed) console.log(` + ${line}`);
|
|
125
|
+
if (skipped.length > 0) {
|
|
126
|
+
console.log(`跳过 ${skipped.length} 条`);
|
|
127
|
+
for (const line of skipped) console.log(` - ${line}`);
|
|
128
|
+
}
|
|
129
|
+
console.log("");
|
|
130
|
+
console.log("修完重启 DSH,或对受影响的插件重新启用一次。");
|