@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/lib/index.js ADDED
@@ -0,0 +1,1199 @@
1
+ /**
2
+ * dsh-downloader — host half.
3
+ *
4
+ * Registers three agent tools and one runtime skill so the model downloads
5
+ * internet files through the plugin's own rule-splitting proxy instead of a
6
+ * bare `curl`:
7
+ *
8
+ * - `dsh_download` streaming download with integrity + automatic routing
9
+ * - `dsh_proxy_status` upstream / node / rule diagnostics
10
+ * - `dsh_geo_check` offline-first domestic-vs-foreign verdict
11
+ *
12
+ * Scope note: this plugin deliberately registers **tools only**. It never
13
+ * mutates `process.env` and never replaces a global dispatcher, so `web_fetch`,
14
+ * `curl`, `git`, and `npm` are untouched. To route those as well, use DSH's
15
+ * built-in launch-time proxy policy (`$DSH_HOME/.env`).
16
+ * @module dsh-downloader
17
+ */
18
+ import z from "@deepseek-ai/schemastery";
19
+ import { isVolatile } from "@deepseek-ai/cosmokit";
20
+ import fs from "node:fs";
21
+ import path from "node:path";
22
+ import { homedir } from "node:os";
23
+ import { UpstreamManager } from "./core/upstream.js";
24
+ import { AI_DIRECT_DOMAINS, composeDirectDomains, normalizeDomain } from "./core/ai-domains.js";
25
+ import { applyGuard, envFilePath, inspectGuard, restoreGuard } from "./core/session-guard.js";
26
+ import { buildShellPrefix, buildShellToolArguments, classifyShellCommand, explainShellRouting } from "./core/shell-proxy.js";
27
+ import { resolveAuth, sanitizeHeaders } from "./download/auth.js";
28
+ import { decideRoute, NoUpstreamError } from "./download/route.js";
29
+ import { downloadWithFallback } from "./download/fetch-file.js";
30
+ import { geoLookupIP, icmpPing, resolveHost, tcpPing } from "./download/net-probe.js";
31
+ import { filenameFromUrl, humanSize, isPrivateIp } from "./download/util.js";
32
+
33
+ /** Cordis plugin name used by loader diagnostics. */
34
+ const name = "dsh-downloader";
35
+
36
+ /**
37
+ * Hard dependency: the tool registry is the plugin's entire public surface.
38
+ * Everything else (`skills`, `webServer`, `settings`, `timer`) is resolved
39
+ * lazily, because a missing service in `inject` parks the whole plugin in
40
+ * `pending` without any error being reported.
41
+ */
42
+ const inject = ["tools"];
43
+
44
+ /** Stable entry id; must equal `cordis.patch.yml`'s `id` and the client's form key. */
45
+ const ENTRY_ID = "dsh-downloader";
46
+
47
+ /** JSON Schema used for every tool's structured output. */
48
+ const OBJECT_OUTPUT_SCHEMA = { type: "object", additionalProperties: true };
49
+
50
+ /** Settings namespace the management panel lives in (informational). */
51
+ const SETTINGS_NAMESPACE = ENTRY_ID;
52
+
53
+ /** Config key order, reused by the client panel. */
54
+ const CONFIG_KEYS = [
55
+ "enabled",
56
+ "proxyUrl",
57
+ "subscriptionUrl",
58
+ "fetchProxyUrl",
59
+ "autoUpdateHours",
60
+ "groupType",
61
+ "preferredNode",
62
+ "latencyTestUrl",
63
+ "latencyTimeoutMs",
64
+ "domesticDirect",
65
+ "protectAiPlatforms",
66
+ "extraDirectDomains",
67
+ "guardShellDownloads",
68
+ "extraRules",
69
+ "excludeRules",
70
+ "downloadDir",
71
+ "maxDownloadMb",
72
+ "downloadTimeoutS",
73
+ "stallTimeoutS",
74
+ "threads",
75
+ "insecureTls",
76
+ "allowOutsideWorkspace",
77
+ "maxRedirects"
78
+ ];
79
+
80
+ /**
81
+ * Every field is `.volatile()` because `@deepseek-ai/dsh-settings` only
82
+ * projects volatile fields into an editable form — without it the management
83
+ * panel has nothing to bind to.
84
+ */
85
+ const Config = z.object({
86
+ enabled: z.boolean().default(true).description("总开关:关闭后下载工具直接失败,不再解析上游").volatile(),
87
+ proxyUrl: z.string().role("secret").default("").description("显式上游代理,如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080").volatile(),
88
+ subscriptionUrl: z.string().role("secret").default("").description("Clash 订阅地址:自包含内核从这里取节点,不需要本机装 Clash").volatile(),
89
+ fetchProxyUrl: z.string().role("secret").default("").description("只用于抓订阅本身的可选 http(s) 代理(订阅被墙时填)").volatile(),
90
+ autoUpdateHours: z.number().step(1).min(0).default(24).description("订阅自动更新间隔(小时,0 = 关闭)").volatile(),
91
+ groupType: z.union([z.const("url-test"), z.const("select"), z.const("fallback")]).default("url-test").description("节点选择策略:自动最快 / 手动 / 失败切换").volatile(),
92
+ preferredNode: z.string().default("").description("按名字固定一个节点(优先级高于上面的策略)").volatile(),
93
+ latencyTestUrl: z.string().default("http://www.gstatic.com/generate_204").description("节点健康检查与测速用的网址").volatile(),
94
+ latencyTimeoutMs: z.number().step(100).min(500).max(10000).default(3000).description("单节点测速超时(毫秒)").volatile(),
95
+ domesticDirect: z.boolean().default(true).description("开启后境内直连、只有境外走代理;关闭则所有下载都走代理(AI 平台仍直连)").volatile(),
96
+ protectAiPlatforms: z.boolean().default(true).description("国内 AI 平台域名强制直连,优先级最高 —— 代理节点抖动或代理程序关闭都不会打断模型连接").volatile(),
97
+ extraDirectDomains: z.array(z.string()).default([]).description("额外强制直连的域名(可填裸域名或完整 URL)").volatile(),
98
+ guardShellDownloads: z.boolean().default(true).description("检测到 git / curl 等 shell 命令要从境外下载时拦下来,改走本插件的代理(可用 dsh_run_proxied 重跑)").volatile(),
99
+ extraRules: z.array(z.string()).default([]).description('追加规则行,如 "DOMAIN-SUFFIX,example.com,DIRECT"').volatile(),
100
+ excludeRules: z.array(z.string()).default([]).description("从订阅规则里剔除的行(子串匹配)").volatile(),
101
+ downloadDir: z.string().default("").description("默认下载目录(留空 = $DSH_HOME/downloads)").volatile(),
102
+ maxDownloadMb: z.number().min(1).default(512).description("单文件大小上限(MB)").volatile(),
103
+ downloadTimeoutS: z.number().min(1).default(600).description("单次下载的总时长预算(秒)").volatile(),
104
+ stallTimeoutS: z.number().min(5).default(30).description("多少秒收不到数据就判定卡死并中止").volatile(),
105
+ threads: z.number().min(1).max(32).default(4).description("多线程下载的分片连接数;1 = 单连接。服务端不支持 Range 时自动回退单连接").volatile(),
106
+ insecureTls: z.boolean().default(false).description("跳过 TLS 证书校验(仅企业 TLS 解密环境需要)").volatile(),
107
+ allowOutsideWorkspace: z.boolean().default(false).description("允许 save_path 写到工作区与配置的下载目录之外").volatile(),
108
+ maxRedirects: z.number().step(1).min(0).max(30).default(10).description("最多跟随多少次重定向").volatile()
109
+ });
110
+
111
+ // ---------------------------------------------------------------------------
112
+ // helpers
113
+ // ---------------------------------------------------------------------------
114
+
115
+ /** Recursively unwrap schemastery `Volatile` containers into plain values. */
116
+ function plain(value) {
117
+ if (isVolatile(value)) return plain(value.get());
118
+ if (Array.isArray(value)) return value.map(plain);
119
+ if (value === null || typeof value !== "object") return value;
120
+ const prototype = Object.getPrototypeOf(value);
121
+ if (prototype !== Object.prototype && prototype !== null) return value;
122
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, plain(child)]));
123
+ }
124
+
125
+ /** Harness home directory. */
126
+ function dshHome() {
127
+ return process.env.DSH_HOME ?? path.join(homedir(), ".dsh");
128
+ }
129
+
130
+ /** Default download directory. */
131
+ function defaultDownloadDir() {
132
+ return path.join(dshHome(), "downloads");
133
+ }
134
+
135
+ /** Case-insensitive containment test that also works for not-yet-existing paths. */
136
+ function isInside(child, parent) {
137
+ const from = path.resolve(parent);
138
+ const to = path.resolve(child);
139
+ if (process.platform === "win32") {
140
+ const a = from.toLowerCase();
141
+ const b = to.toLowerCase();
142
+ return b === a || b.startsWith(a.endsWith(path.sep) ? a : a + path.sep);
143
+ }
144
+ return to === from || to.startsWith(from.endsWith("/") ? from : `${from}/`);
145
+ }
146
+
147
+ /** The session workspace root, best effort. */
148
+ function workspaceRoot(ctx, exec) {
149
+ const fromAgent = exec?.agent?.session?.header?.cwd;
150
+ if (typeof fromAgent === "string" && fromAgent.length > 0) return fromAgent;
151
+ const profile = ctx.get?.("profileContext");
152
+ if (profile !== undefined && typeof profile.cwd === "string" && profile.cwd.length > 0) return profile.cwd;
153
+ return process.cwd();
154
+ }
155
+
156
+ /** Turn a tool's JSON value into the model-facing text block. */
157
+ function text(value) {
158
+ return [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }];
159
+ }
160
+
161
+ /** Render a possibly-absent field without printing `undefined` or `NaN` at the model. */
162
+ function shown(value, fallback = "(未知)") {
163
+ if (value === null || value === undefined) return fallback;
164
+ if (typeof value === "number" && !Number.isFinite(value)) return fallback;
165
+ return value;
166
+ }
167
+
168
+ /** Render one download result as a compact, quotable report. */
169
+ function renderDownload(value) {
170
+ if (value === null || typeof value !== "object") return text(value);
171
+ const lines = [];
172
+ if (value.ok !== true) {
173
+ lines.push(`下载失败:${shown(value.error, "未知错误")}`);
174
+ if (value.hint !== null && value.hint !== undefined) lines.push(`建议:${value.hint}`);
175
+ return text(lines.join("\n"));
176
+ }
177
+ lines.push(`已下载 ${humanSize(value.bytes ?? 0)},耗时 ${(((value.elapsed_ms ?? 0) / 1000)).toFixed(1)} 秒(${humanSize(value.speed_bps ?? 0)}/s)`);
178
+ lines.push(`保存到:${shown(value.saved_to)}`);
179
+ lines.push(`路由:${routeLabel(value.route)} —— ${shown(value.route_reason)}`);
180
+ if (value.fallback_used === true) lines.push("说明:直连失败,已改用代理重试并成功");
181
+ if (value.upstream !== null && value.upstream !== undefined) {
182
+ lines.push(
183
+ `上游:${modeLabel(value.upstream.mode)}${value.upstream.node === null || value.upstream.node === undefined ? "" : `,节点=${value.upstream.node}(${shown(value.upstream.node_type)})`}`
184
+ );
185
+ }
186
+ lines.push(`sha256:${shown(value.sha256)}`);
187
+ lines.push(
188
+ value.via_threads === true
189
+ ? `传输:${shown(value.segments, "多")} 个分片并发下载`
190
+ : "传输:单连接(服务端不支持 Range,或已回退)"
191
+ );
192
+ const http = value.http ?? {};
193
+ const declared = http.content_length;
194
+ const declaredText = typeof declared === "number" && Number.isFinite(declared) ? `,声明大小 ${humanSize(declared)}` : "";
195
+ lines.push(`HTTP:${shown(http.status)}${http.redirects > 0 ? `,经过 ${http.redirects} 次重定向` : ""}${declaredText}`);
196
+ if (Array.isArray(value.warnings) && value.warnings.length > 0) lines.push(`提醒:${value.warnings.join(";")}`);
197
+ return text(lines.join("\n"));
198
+ }
199
+
200
+ /** Chinese label for a route value. */
201
+ function routeLabel(route) {
202
+ if (route === "proxy") return "proxy(走代理)";
203
+ if (route === "direct") return "direct(直连)";
204
+ return shown(route);
205
+ }
206
+
207
+ /** Chinese label for a state value. */
208
+ function stateLabel(state) {
209
+ switch (state) {
210
+ case "running":
211
+ return "running(运行中)";
212
+ case "disabled":
213
+ return "disabled(已关闭)";
214
+ case "no-subscription":
215
+ return "no-subscription(无可用订阅)";
216
+ case "failed":
217
+ return "failed(失败)";
218
+ case "idle":
219
+ return "idle(未启动)";
220
+ default:
221
+ return shown(state);
222
+ }
223
+ }
224
+
225
+ /** Chinese label for an upstream mode value. */
226
+ function modeLabel(mode) {
227
+ switch (mode) {
228
+ case "explicit":
229
+ return "explicit(配置的 proxyUrl)";
230
+ case "environment":
231
+ return "environment(启动环境代理)";
232
+ case "subscription":
233
+ return "subscription(自包含订阅)";
234
+ case "none":
235
+ return "none(未配置)";
236
+ default:
237
+ return shown(mode);
238
+ }
239
+ }
240
+
241
+ /** Chinese label for a session-guard action value. */
242
+ function actionLabel(action) {
243
+ switch (action) {
244
+ case "check":
245
+ return "check(只读检查)";
246
+ case "apply":
247
+ return "apply(写入 NO_PROXY)";
248
+ case "restore":
249
+ return "restore(从备份还原)";
250
+ default:
251
+ return shown(action);
252
+ }
253
+ }
254
+
255
+ /** 渲染一份配置的流量/到期信息。 */
256
+ function trafficText(info) {
257
+ if (info === null || info === undefined) return "";
258
+ if (!info.total && !info.expire) return "";
259
+ const used = (info.upload ?? 0) + (info.download ?? 0);
260
+ const parts = [];
261
+ if (info.total) parts.push(`已用 ${humanSize(used)} / ${humanSize(info.total)}`);
262
+ if (info.expire) parts.push(`到期 ${new Date(info.expire * 1000).toLocaleDateString()}`);
263
+ return parts.length === 0 ? "" : `(${parts.join(",")})`;
264
+ }
265
+
266
+ /** 渲染 `dsh_run_proxied` 的结果。 */
267
+ function renderProxiedRun(value) {
268
+ if (value === null || typeof value !== "object") return text(value);
269
+ const lines = [value.executed === true ? "已通过本插件的代理执行这条命令。" : "已生成带代理的命令(未执行)。"];
270
+ if (value.command) lines.push(`命令:${value.command}`);
271
+ if (value.executed === true) {
272
+ const output = typeof value.output === "string" ? value.output.trim() : "";
273
+ lines.push(output.length > 0 ? `输出:\n${output}` : "(命令没有任何输出)");
274
+ }
275
+ lines.push("说明:代理只作用于这一次 shell 调用;国内目标仍由插件内核判定直连,国内 AI 平台域名直接绕过。");
276
+ if (value.exit_note) lines.push(value.exit_note);
277
+ return text(lines.join("\n"));
278
+ }
279
+
280
+ /** 渲染 `dsh_profile` 的配置列表。 */
281
+ function renderProfiles(value) {
282
+ if (value === null || typeof value !== "object") return text(value);
283
+ const list = Array.isArray(value.profiles) ? value.profiles : [];
284
+ const lines = [`动作:${shown(value.action)}`];
285
+ if (list.length === 0) {
286
+ lines.push("还没有导入任何配置。");
287
+ } else {
288
+ lines.push(`共 ${list.length} 份配置,当前使用:${shown(value.current_label, "无")}`);
289
+ for (const profile of list) {
290
+ const marks = [profile.current ? "当前" : null, profile.source === "file" ? "本地文件" : null].filter(Boolean);
291
+ const auto = profile.source === "file"
292
+ ? "本地文件不自动更新"
293
+ : profile.auto_update === true
294
+ ? `每 ${profile.auto_update_hours} 小时自动更新`
295
+ : "自动更新已关闭";
296
+ // 只有当前使用的配置才实际解析过,其余不报节点数。
297
+ const nodes = profile.current === true && typeof profile.node_count === "number" ? `${profile.node_count} 个节点,` : "";
298
+ lines.push(
299
+ ` - ${profile.label}${marks.length > 0 ? `(${marks.join("、")})` : ""}:${nodes}${auto},` +
300
+ `上次更新 ${profile.last_update_at ? new Date(profile.last_update_at).toLocaleString() : "从未"}` +
301
+ `${trafficText(profile.subscription_info)}`
302
+ );
303
+ }
304
+ }
305
+ if (Array.isArray(value.notes) && value.notes.length > 0) lines.push(...value.notes);
306
+ return text(lines.join("\n"));
307
+ }
308
+
309
+ /** One-line summary of the `$DSH_HOME/.env` session guard. */
310
+ function guardLine(guard) {
311
+ if (guard === null || guard === undefined || typeof guard !== "object") return "未知";
312
+ if (guard.covered === true) {
313
+ return guard.exists === true
314
+ ? "$DSH_HOME/.env 已让所有 AI 平台域名绕过全局代理"
315
+ : "没有配置全局代理,无需绕过";
316
+ }
317
+ const missing = Array.isArray(guard.missing) ? guard.missing.length : 0;
318
+ return `$DSH_HOME/.env 的 NO_PROXY 还缺 ${missing} 个 AI 平台域名 —— 用 dsh_session_guard 的 action="apply" 补上`;
319
+ }
320
+
321
+ /** Render the status object as readable lines (the raw JSON stays in `value`). */
322
+ function renderStatus(value) {
323
+ if (value === null || typeof value !== "object") return text(value);
324
+ const lines = [
325
+ `状态:${stateLabel(value.state)}`,
326
+ `上游模式:${modeLabel(value.mode)} —— ${shown(value.reason)}`,
327
+ `规则内核:${value.port > 0 ? `127.0.0.1:${value.port}` : "未运行"}`,
328
+ `节点数:${shown(value.node_count, 0)}${value.unsupported_nodes > 0 ? `(另有 ${value.unsupported_nodes} 个不受支持)` : ""}`,
329
+ `当前节点:${shown(value.selected, "(自动选择)")}`,
330
+ `境内外分流:${value.domestic_direct === false ? "关闭(全部走代理)" : "开启(境内直连)"}`,
331
+ `AI 平台强制直连:${value.ai_protected === false ? "已关闭" : `开启(${shown(value.ai_direct_domain_count, 0)} 个域名)`}`,
332
+ `会话保护:${guardLine(value.session_guard)}`,
333
+ `代理作用域:${value.scope?.loopbackOnly === true ? "仅 127.0.0.1 回环" : "未知"}${value.scope?.tokenGated === true ? " + 本进程令牌鉴权(其它程序借不走,一律被拒)" : ""}`,
334
+ `节点配置:${value.profile_count > 0 ? `${value.profile_count} 份,当前「${shown(value.current_profile?.label)}」` : value.subscription_url_set === true ? "使用旧版单条订阅地址" : "未配置"}`,
335
+ `原生连接器:${value.native_connector === true ? "已内置" : "未内置(hysteria2 / reality 节点会被跳过)"}`
336
+ ];
337
+ if (value.profile_count > 0) {
338
+ lines.push(`旧版单条订阅地址:${value.subscription_url_set === true ? "已填(当前不生效,被上面的配置覆盖)" : "未填"}`);
339
+ } else {
340
+ lines.push(`旧版单条订阅地址:${value.subscription_url_set === true ? "已填" : "未填"} | proxyUrl:${value.proxy_url_set === true ? "已填" : "未填"} | 启动环境代理:${shown(value.env_proxy, "无")}`);
341
+ }
342
+ if (value.last_subscription_update) lines.push(`订阅更新时间:${new Date(value.last_subscription_update).toLocaleString()}`);
343
+ if (value.error !== null && value.error !== undefined) lines.push(`最近错误:${value.error}`);
344
+ if (Array.isArray(value.nodes) && value.nodes.length > 0) {
345
+ lines.push("节点列表:");
346
+ for (const node of value.nodes.slice(0, 40)) {
347
+ const delay = typeof node.delay === "number" && Number.isFinite(node.delay) ? ` ${node.delay}ms` : "";
348
+ lines.push(` - ${node.name} [${shown(node.type)}]${delay}${node.needs_native ? "(需原生连接器)" : ""}`);
349
+ }
350
+ if (value.nodes.length > 40) lines.push(` …… 另有 ${value.nodes.length - 40} 个`);
351
+ }
352
+ if (Array.isArray(value.notes) && value.notes.length > 0) lines.push(...value.notes);
353
+ return text(lines.join("\n"));
354
+ }
355
+
356
+ /** Render a geo verdict. */
357
+ function renderGeo(value) {
358
+ if (value === null || typeof value !== "object") return text(value);
359
+ const ips = Array.isArray(value.resolved_ips) ? value.resolved_ips : [];
360
+ const geo = value.geo ?? null;
361
+ const ping = value.ping ?? null;
362
+ const lines = [
363
+ `${shown(value.host)} → ${routeLabel(value.route)}`,
364
+ `依据:${shown(value.reason)}`,
365
+ `解析结果:${ips.length === 0 ? "(无)" : ips.join(", ")}`,
366
+ `归属地:${geo === null ? "未知" : `${shown(geo.country, "?")}(${shown(geo.country_code, "?")}),来源 ${shown(geo.provider, "?")}`}${value.geo_source === null || value.geo_source === undefined ? "" : ` [${value.geo_source}]`}`,
367
+ `连通性:${ping === null ? "未探测" : `icmp=${ping.icmp?.ok === true ? `${ping.icmp.time_ms}ms` : "失败"},tcp=${ping.tcp_443?.ok === true ? `${ping.tcp_443.time_ms}ms` : "失败"}`}`
368
+ ];
369
+ if (value.advice !== null && value.advice !== undefined) lines.push(`建议:${value.advice}`);
370
+ return text(lines.join("\n"));
371
+ }
372
+
373
+ // ---------------------------------------------------------------------------
374
+ // plugin
375
+ // ---------------------------------------------------------------------------
376
+
377
+ /** @param ctx - plugin context. @param config - the row's config (volatile-wrapped). */
378
+ function apply(ctx, config) {
379
+ const live = config !== null && typeof config === "object" ? config : {};
380
+ // Re-read on every access: the loader mutates the Volatile containers in
381
+ // place when the management panel writes a field.
382
+ const resolveConfig = () => plain(live);
383
+
384
+ const upstream = new UpstreamManager(ctx, resolveConfig, { dataDirName: ENTRY_ID });
385
+ const disposers = [];
386
+ const log = {
387
+ info: (message, ...args) => ctx.logger?.info?.(`dsh-downloader: ${message}`, ...args),
388
+ warn: (message, ...args) => ctx.logger?.warn?.(`dsh-downloader: ${message}`, ...args)
389
+ };
390
+
391
+ // ---- settings panel: react to writes without re-mounting the plugin ----
392
+ try {
393
+ ctx.on("loader/volatile-update", () => {
394
+ void upstream.resolve({ refresh: true }).catch((error) => log.warn("reload after a settings change failed: %s", String(error)));
395
+ });
396
+ } catch {
397
+ // The loader event is optional; the panel still persists config.
398
+ }
399
+ // Show our own page instead of an auto-generated one.
400
+ ctx.inject?.(['settings'], (settingsCtx) => {
401
+ try {
402
+ settingsCtx.settings?.configure?.({ auto: false }, ctx.fiber);
403
+ } catch {
404
+ /* the settings service may not implement configure() */
405
+ }
406
+ });
407
+ // Optional interval service: enables subscription auto-update and the
408
+ // periodic latency sweep. Absent => the core still works, just manually.
409
+ ctx.inject?.(['timer'], (timerCtx) => {
410
+ try {
411
+ upstream.attachScheduler((callback, delayMs) => timerCtx.setInterval(callback, delayMs));
412
+ } catch (error) {
413
+ log.warn("could not attach the timer service: %s", String(error));
414
+ }
415
+ });
416
+
417
+ // ---- shared state for the tool implementations ----
418
+ const inFlight = new Set();
419
+
420
+ /**
421
+ * Resolve the destination path, enforcing the workspace boundary.
422
+ * @returns the absolute destination path.
423
+ */
424
+ function resolveDestination(target, exec) {
425
+ const section = resolveConfig();
426
+ const url = String(target.url ?? "").trim();
427
+ const suggested = filenameFromUrl(url) || "download.bin";
428
+ const configuredDir = typeof section.downloadDir === "string" && section.downloadDir.trim().length > 0
429
+ ? section.downloadDir.trim()
430
+ : defaultDownloadDir();
431
+ const raw = typeof target.save_path === "string" ? target.save_path.trim() : "";
432
+
433
+ let destination;
434
+ if (raw.length === 0) {
435
+ destination = path.join(configuredDir, suggested);
436
+ } else {
437
+ const absolute = path.resolve(raw);
438
+ const looksLikeDirectory = /[\\/]$/.test(raw) || (fs.existsSync(absolute) && fs.statSync(absolute).isDirectory());
439
+ destination = looksLikeDirectory ? path.join(absolute, suggested) : absolute;
440
+ }
441
+
442
+ const allowedRoots = [workspaceRoot(ctx, exec), configuredDir, defaultDownloadDir()];
443
+ const permitted = section.allowOutsideWorkspace === true || allowedRoots.some((root) => isInside(destination, root));
444
+ if (!permitted) {
445
+ throw new Error(
446
+ `拒绝写到工作区之外:${destination}。请在「设置 → 下载代理」里把 allowOutsideWorkspace 打开,` +
447
+ `或改存到 ${configuredDir},或传一个位于 ${workspaceRoot(ctx, exec)} 之内的 save_path。`
448
+ );
449
+ }
450
+ return destination;
451
+ }
452
+
453
+ // ---- dsh_download ------------------------------------------------------
454
+ disposers.push(
455
+ ctx.tools.register({
456
+ name: "dsh_download",
457
+ description:
458
+ "从互联网下载文件到本地磁盘。插件自己决定走哪条路:境内目标直连,境外目标走配置好的代理" +
459
+ "(自包含的订阅内核,或显式指定的 proxyUrl),直连失败会自动改用代理重试。" +
460
+ "返回保存路径、字节数、sha256、速度和实际使用的路由。下载互联网文件请优先用它,不要用 curl / Invoke-WebRequest。",
461
+ parameters: {
462
+ type: "object",
463
+ properties: {
464
+ url: { type: "string", description: "完整的 http:// 或 https:// 下载地址。" },
465
+ save_path: { type: "string", description: "目标文件路径,或一个目录(目录时按 URL 自动取文件名)。默认写到配置的下载目录。" },
466
+ overwrite: { type: "boolean", description: "覆盖已存在的目标文件。默认 false。" },
467
+ force_route: { type: "string", enum: ["auto", "proxy", "direct"], description: "auto(默认)按规则引擎分流;direct / proxy 强制走其中一条。" },
468
+ max_mb: { type: "number", description: "本次调用覆盖配置的单文件大小上限(MB)。" },
469
+ timeout_seconds: { type: "number", description: "本次调用覆盖配置的总时长预算(秒)。" },
470
+ threads: { type: "number", description: "本次调用覆盖分片连接数(1 = 单连接)。服务端不支持 Range 时自动回退单连接。" },
471
+ auth: {
472
+ type: "string",
473
+ description:
474
+ "鉴权引用,用于需要 token 的下载(GitHub Actions 产物、私有 Release 等)。" +
475
+ '取值:`gh`(插件自己跑 gh auth token 取 GitHub 凭据)、`env:变量名`(读环境变量)。' +
476
+ "**优先用这两个** —— 它们不会把 token 写进会话记录。仅非敏感场景才用 `bearer:token`。"
477
+ },
478
+ headers: {
479
+ type: "object",
480
+ description: "附加请求头(键值都是字符串)。Host / Content-Length / Connection 这类会破坏请求的头会被忽略。",
481
+ additionalProperties: { type: "string" }
482
+ }
483
+ },
484
+ required: ["url"],
485
+ additionalProperties: false
486
+ },
487
+ output: { schema: OBJECT_OUTPUT_SCHEMA, render: (_args, value) => renderDownload(value) },
488
+ isConcurrencySafe: () => true,
489
+ presentCall: (args) => ({ card: "generic", kind: "fetch", title: "下载文件", rawInput: String(args?.url ?? "") }),
490
+ presentResult: (args, result) => ({ card: "generic", title: result?.isError === true ? "下载失败" : "下载完成" }),
491
+ execute: async (args, exec) => {
492
+ const section = resolveConfig();
493
+ const url = String(args?.url ?? "").trim();
494
+ if (url.length === 0) throw new Error("缺少 url 参数");
495
+ const forceRoute = ["auto", "proxy", "direct"].includes(String(args?.force_route ?? "auto")) ? String(args?.force_route ?? "auto") : "auto";
496
+
497
+ try {
498
+ const view = await upstream.resolve();
499
+ const warnings = [];
500
+ if (view.mode === "none") warnings.push("没有配置代理,任何目标都无法走代理");
501
+ else if (view.available !== true) warnings.push(`代理内核不可用(${view.reason})`);
502
+
503
+ const decision = decideRoute(url, view, forceRoute);
504
+ if (decision.warning !== null) warnings.push(decision.warning);
505
+
506
+ const destination = resolveDestination(args, exec);
507
+ const maxBytes = Math.max(1, Number(args?.max_mb) > 0 ? Number(args.max_mb) : section.maxDownloadMb) * 1024 * 1024;
508
+ const timeoutS = Number(args?.timeout_seconds) > 0 ? Number(args.timeout_seconds) : section.downloadTimeoutS;
509
+
510
+ // 鉴权:只接受「引用」,token 由插件自己去取,不进会话记录。
511
+ const auth = await resolveAuth(args?.auth);
512
+ if (auth.warning !== null) warnings.push(auth.warning);
513
+ const requestHeaders = { ...sanitizeHeaders(args?.headers), ...auth.headers };
514
+
515
+ inFlight.add(destination);
516
+ let result;
517
+ try {
518
+ result = await downloadWithFallback({
519
+ url,
520
+ savePath: destination,
521
+ overwrite: args?.overwrite === true,
522
+ decidedRoute: decision,
523
+ upstream: view,
524
+ forceRoute,
525
+ maxBytes,
526
+ timeoutS,
527
+ stallS: section.stallTimeoutS,
528
+ threads: Number(args?.threads) > 0 ? Number(args.threads) : section.threads,
529
+ insecure: section.insecureTls === true,
530
+ maxRedirects: section.maxRedirects,
531
+ headers: Object.keys(requestHeaders).length > 0 ? requestHeaders : null,
532
+ signal: exec.signal
533
+ });
534
+ } finally {
535
+ inFlight.delete(destination);
536
+ }
537
+
538
+ return {
539
+ ok: true,
540
+ saved_to: result.savedTo,
541
+ bytes: result.bytes,
542
+ sha256: result.sha256,
543
+ speed_bps: result.speedBps,
544
+ elapsed_ms: result.elapsedMs,
545
+ route: result.route,
546
+ route_reason: result.routeReason,
547
+ fallback_used: result.fallbackUsed,
548
+ via_threads: result.viaThreads === true,
549
+ segments: result.segments ?? null,
550
+ upstream: { mode: view.mode, node: view.nodeName, node_type: view.nodeType },
551
+ http: result.http,
552
+ final_url: result.finalUrl,
553
+ warnings
554
+ };
555
+ } catch (error) {
556
+ const message = error instanceof Error ? error.message : String(error);
557
+ const hint = error instanceof NoUpstreamError ? error.hint : null;
558
+ const view = upstream.view();
559
+ log.warn("download failed: %s", message);
560
+ throw new Error(
561
+ `${message}\n 上游模式:${modeLabel(view.mode)} —— ${view.reason}\n 建议:${hint ?? "检查 URL、目标路径,以及「设置 → 下载代理」"}`
562
+ );
563
+ }
564
+ }
565
+ })
566
+ );
567
+
568
+ // ---- dsh_proxy_status --------------------------------------------------
569
+ disposers.push(
570
+ ctx.tools.register({
571
+ name: "dsh_proxy_status",
572
+ description:
573
+ "汇报下载代理的当前状态:上游模式、订阅是否已加载、节点数量与延迟、当前选中的节点、本地规则内核端口、AI 平台保护状态、" +
574
+ "会话保护状态,以及最近一次错误。用来排查「下载为什么走了 / 没走代理」。",
575
+ parameters: {
576
+ type: "object",
577
+ properties: {
578
+ refresh: { type: "boolean", description: "强制完整重新解析(重抓订阅、重建规则内核)。" },
579
+ test_all: { type: "boolean", description: "同时对全部节点测速(较慢)。" },
580
+ include_nodes: { type: "boolean", description: "是否在结果里带上节点列表。默认 true。" }
581
+ },
582
+ additionalProperties: false
583
+ },
584
+ output: { schema: OBJECT_OUTPUT_SCHEMA, render: (_args, value) => renderStatus(value) },
585
+ isConcurrencySafe: () => true,
586
+ presentCall: () => ({ card: "generic", kind: "other", title: "检查下载代理" }),
587
+ presentResult: () => ({ card: "generic", title: "下载代理状态" }),
588
+ execute: async (args) => {
589
+ const view = await upstream.resolve({ refresh: args?.refresh === true });
590
+ if (args?.test_all === true && view.available) await upstream.testAllLatencies();
591
+ const status = upstream.status();
592
+ const notes = [];
593
+ if (status.nativeNodes > 0 && status.nativeConnector !== true) {
594
+ notes.push(`提示:有 ${status.nativeNodes} 个节点需要可选的 Go 原生连接器,当前构建未打包,这些节点会被跳过。`);
595
+ }
596
+ if (status.mode === "none") {
597
+ notes.push("提示:请在「设置 → 下载代理」里填订阅地址或 proxyUrl,才能让境外下载走代理。");
598
+ }
599
+ if (status.error !== null && status.error !== undefined) notes.push(`提示:${status.error}`);
600
+ // The tool surface is snake_case; the management API keeps the
601
+ // service's camelCase for the browser panel.
602
+ const payload = {
603
+ state: status.state,
604
+ error: status.error,
605
+ mode: status.mode,
606
+ reason: status.reason,
607
+ enabled: status.enabled,
608
+ port: status.port,
609
+ node_count: status.nodeCount,
610
+ unsupported_nodes: status.unsupportedNodes,
611
+ native_connector: status.nativeConnector,
612
+ native_nodes: status.nativeNodes,
613
+ selected: status.selected,
614
+ group_type: status.groupType,
615
+ domestic_direct: status.domesticDirect,
616
+ ai_protected: status.protectAiPlatforms,
617
+ ai_direct_domain_count: status.protectedDomains.length,
618
+ ai_direct_domains: status.protectedDomains,
619
+ session_guard: upstream.guardState(),
620
+ scope: status.scope,
621
+ profile_count: status.profileCount,
622
+ current_profile: status.currentProfile,
623
+ profiles: status.profiles.map((profile) => ({
624
+ id: profile.id,
625
+ label: profile.label,
626
+ source: profile.source,
627
+ current: profile.current,
628
+ auto_update: profile.autoUpdate,
629
+ auto_update_hours: profile.autoUpdateHours,
630
+ last_update_at: profile.lastUpdateAt,
631
+ subscription_info: profile.subscriptionInfo
632
+ })),
633
+ subscription_url_set: status.subscriptionUrlSet,
634
+ proxy_url_set: status.proxyUrlSet,
635
+ env_proxy: status.envProxy,
636
+ last_subscription_update: status.lastSubscriptionUpdate,
637
+ latency_test_url: status.latencyTestUrl,
638
+ auto_update_hours: status.autoUpdateHours,
639
+ data_dir: status.dataDir,
640
+ traffic: status.traffic,
641
+ groups: upstream.groups().map((group) => ({ name: group.name, type: group.type, now: group.now, size: group.all.length })),
642
+ notes
643
+ };
644
+ if (args?.include_nodes !== false) {
645
+ payload.nodes = upstream.nodeList().map((node) => ({
646
+ name: node.name,
647
+ type: node.type,
648
+ server: node.server,
649
+ port: node.port,
650
+ delay: node.delay,
651
+ alive: node.alive,
652
+ needs_native: node.needsNative
653
+ }));
654
+ }
655
+ return payload;
656
+ }
657
+ })
658
+ );
659
+
660
+ // ---- dsh_geo_check -----------------------------------------------------
661
+ disposers.push(
662
+ ctx.tools.register({
663
+ name: "dsh_geo_check",
664
+ description:
665
+ "判断一个 URL 或主机名是境内还是境外,以及下载器会走哪条路。判定优先使用内置的离线规则库(完全不发网络请求);" +
666
+ "只有规则库判不出来时才做 DNS、在线 GeoIP 与 ping 探测。",
667
+ parameters: {
668
+ type: "object",
669
+ properties: {
670
+ url_or_host: { type: "string", description: "一个 URL,或裸主机名 / IP 字面量。" },
671
+ ping: { type: "boolean", description: "是否同时做 ICMP + TCP 探测。默认 true。" }
672
+ },
673
+ required: ["url_or_host"],
674
+ additionalProperties: false
675
+ },
676
+ output: { schema: OBJECT_OUTPUT_SCHEMA, render: (_args, value) => renderGeo(value) },
677
+ isConcurrencySafe: () => true,
678
+ presentCall: (args) => ({ card: "generic", kind: "search", title: "检查主机走哪条路由", rawInput: String(args?.url_or_host ?? "") }),
679
+ presentResult: () => ({ card: "generic", title: "路由判定结果" }),
680
+ execute: async (args, exec) => {
681
+ const input = String(args?.url_or_host ?? "").trim();
682
+ if (input.length === 0) throw new Error("缺少 url_or_host 参数");
683
+ return await geoCheck(input, { ping: args?.ping !== false, upstream, exec });
684
+ }
685
+ })
686
+ );
687
+
688
+ // ---- dsh_session_guard -------------------------------------------------
689
+ disposers.push(
690
+ ctx.tools.register({
691
+ name: "dsh_session_guard",
692
+ description:
693
+ "代理失能时保住模型连接。check 只读上报 $DSH_HOME/.env 里 NO_PROXY 对国内 AI 平台域名的覆盖情况;" +
694
+ "apply 把缺失的域名合并进去(写前先备份,且绝不改动 HTTP_PROXY / HTTPS_PROXY);restore 用最新备份还原。" +
695
+ "这些域名进了 NO_PROXY 之后,关掉代理程序或节点掉线都不会再打断会话的长连接。",
696
+ parameters: {
697
+ type: "object",
698
+ properties: {
699
+ action: { type: "string", enum: ["check", "apply", "restore"], description: "check(默认)只读;apply 备份后写入;restore 还原到最新备份。" },
700
+ extra_domains: { type: "array", items: { type: "string" }, description: "本次额外强制直连的域名(可填裸域名或完整 URL)。" }
701
+ },
702
+ additionalProperties: false
703
+ },
704
+ output: { schema: OBJECT_OUTPUT_SCHEMA, render: (_args, value) => renderGuard(value) },
705
+ isConcurrencySafe: () => true,
706
+ presentCall: (args) => ({ card: "generic", kind: "other", title: `会话保护:${args?.action ?? "check"}` }),
707
+ presentResult: () => ({ card: "generic", title: "会话保护结果" }),
708
+ execute: async (args) => {
709
+ const section = resolveConfig();
710
+ const requested = ["check", "apply", "restore"].includes(String(args?.action ?? "check")) ? String(args?.action ?? "check") : "check";
711
+ const extra = (Array.isArray(args?.extra_domains) ? args.extra_domains : []).map(normalizeDomain).filter((domain) => domain.length > 0);
712
+ const domains = composeDirectDomains({
713
+ protectAiPlatforms: section.protectAiPlatforms !== false,
714
+ extraDirectDomains: [...(section.extraDirectDomains ?? []), ...extra]
715
+ });
716
+ const envPath = envFilePath(process.env.DSH_HOME);
717
+ const notes = [];
718
+
719
+ let receipt = null;
720
+ if (requested === "apply") receipt = applyGuard({ envPath, domains });
721
+ else if (requested === "restore") receipt = restoreGuard({ envPath });
722
+
723
+ const state = inspectGuard({ envPath, domains });
724
+ if (requested === "apply" && receipt?.changed === true) {
725
+ notes.push("提示:DSH 只在启动时读一次 $DSH_HOME/.env,所以要重启 DSH 这份全局代理策略才会生效。");
726
+ }
727
+ if (state.proxy_vars.length === 0) {
728
+ notes.push("提示:该文件没有配置 HTTP_PROXY / HTTPS_PROXY / ALL_PROXY,在配上其中一个之前这条 NO_PROXY 暂时不起作用。");
729
+ }
730
+ if (!state.covered) {
731
+ notes.push(`提示:还缺 ${state.missing.slice(0, 8).join("、")}${state.missing.length > 8 ? ` 等 ${state.missing.length} 个域名` : ""}。用 action="apply" 合并进去。`);
732
+ }
733
+
734
+ return {
735
+ action: requested,
736
+ env_path: envPath,
737
+ exists: state.exists,
738
+ proxy_vars: state.proxy_vars,
739
+ no_proxy: state.no_proxy_set ? state.suggested_line.slice("NO_PROXY=".length) : "",
740
+ no_proxy_line: state.suggested_line,
741
+ covered: state.covered,
742
+ missing_count: state.missing.length,
743
+ protected_domain_count: domains.length,
744
+ protected_domains: domains,
745
+ changed: receipt === null ? null : receipt.changed,
746
+ added_count: receipt === null ? 0 : receipt.added.length,
747
+ backup_path: receipt === null ? null : receipt.backupPath,
748
+ restored: requested === "restore",
749
+ notes
750
+ };
751
+ }
752
+ })
753
+ );
754
+
755
+ // ---- dsh_profile -------------------------------------------------------
756
+ disposers.push(
757
+ ctx.tools.register({
758
+ name: "dsh_profile",
759
+ description:
760
+ "管理节点配置,行为对齐 FlClash:一份配置就是一条订阅地址或一个导入的配置文件,带自己的名字、上次更新时间与自动更新间隔。" +
761
+ "支持列出现有配置、从订阅地址或 clash:// 深链导入、从配置正文导入、更新、选择、删除、开关自动更新、排序与改名。" +
762
+ "导入后下载工具会立刻使用选中的那份配置。",
763
+ parameters: {
764
+ type: "object",
765
+ properties: {
766
+ action: {
767
+ type: "string",
768
+ enum: ["list", "add_url", "add_file", "update", "select", "remove", "auto", "reorder", "rename"],
769
+ description: "list(默认)列出;add_url / add_file 导入;update 重新下载;select 选用;remove 删除;auto 开关自动更新;reorder 排序;rename 改名。"
770
+ },
771
+ value: { type: "string", description: "add_url 用:订阅地址,或 clash://install-config?url=… 深链。" },
772
+ label: { type: "string", description: "可选的名字;留空则用响应头里的文件名,再退回域名。" },
773
+ content: { type: "string", description: "add_file 用:配置文件正文(YAML 或 base64 包裹的 YAML)。" },
774
+ name: { type: "string", description: "add_file 用:文件名,用于推导名字。" },
775
+ id: { type: "string", description: "update / select / remove / auto / rename 用:目标配置 id。" },
776
+ enabled: { type: "boolean", description: "auto 用:是否开启自动更新。" },
777
+ hours: { type: "number", description: "auto 用:自动更新间隔(小时)。" },
778
+ ids: { type: "array", items: { type: "string" }, description: "reorder 用:按期望顺序排列的配置 id。" }
779
+ },
780
+ additionalProperties: false
781
+ },
782
+ output: { schema: OBJECT_OUTPUT_SCHEMA, render: (_args, value) => renderProfiles(value) },
783
+ isConcurrencySafe: () => false,
784
+ presentCall: (args) => ({ card: "generic", kind: "other", title: `节点配置:${args?.action ?? "list"}` }),
785
+ presentResult: () => ({ card: "generic", title: "节点配置" }),
786
+ execute: async (args) => {
787
+ const requested = String(args?.action ?? "list");
788
+ const notes = [];
789
+ const snapshot = async () => {
790
+ const view = await upstream.resolve();
791
+ const status = upstream.status();
792
+ return {
793
+ action: requested,
794
+ ok: true,
795
+ current_id: status.currentProfile?.id ?? null,
796
+ current_label: status.currentProfile?.label ?? null,
797
+ node_count: view.available ? status.nodeCount : 0,
798
+ profiles: status.profiles.map((profile) => ({
799
+ ...profile,
800
+ node_count: profile.current ? status.nodeCount : null,
801
+ subscription_info: profile.subscriptionInfo
802
+ })),
803
+ notes
804
+ };
805
+ };
806
+
807
+ switch (requested) {
808
+ case "list":
809
+ return await snapshot();
810
+ case "add_url": {
811
+ if (typeof args?.value !== "string" || args.value.trim().length === 0) throw new Error("缺少 value(订阅地址或 clash:// 深链)");
812
+ const profile = await upstream.addProfileFromUrl(args.value, {
813
+ label: typeof args?.label === "string" ? args.label : undefined,
814
+ autoUpdateHours: Number(args?.hours) > 0 ? Number(args.hours) : undefined
815
+ });
816
+ notes.push(`已导入「${profile.label}」。`);
817
+ return await snapshot();
818
+ }
819
+ case "add_file": {
820
+ if (typeof args?.content !== "string" || args.content.trim().length === 0) throw new Error("缺少 content(配置文件正文)");
821
+ const profile = await upstream.addProfileFromFile(typeof args?.name === "string" && args.name.length > 0 ? args.name : "本地配置.yaml", args.content);
822
+ notes.push(`已导入「${profile.label}」(本地文件配置,不会自动更新)。`);
823
+ return await snapshot();
824
+ }
825
+ case "update": {
826
+ if (typeof args?.id !== "string") throw new Error("缺少 id");
827
+ const profile = await upstream.updateProfile(args.id);
828
+ notes.push(`已更新「${profile.label}」。`);
829
+ return await snapshot();
830
+ }
831
+ case "select": {
832
+ if (typeof args?.id !== "string") throw new Error("缺少 id");
833
+ const profile = await upstream.selectProfile(args.id);
834
+ notes.push(`已切换到「${profile.label}」。`);
835
+ return await snapshot();
836
+ }
837
+ case "remove": {
838
+ if (typeof args?.id !== "string") throw new Error("缺少 id");
839
+ const current = await upstream.removeProfile(args.id);
840
+ notes.push(current === null ? "已删除,当前没有剩余配置。" : `已删除,当前使用「${current.label}」。`);
841
+ return await snapshot();
842
+ }
843
+ case "auto": {
844
+ if (typeof args?.id !== "string") throw new Error("缺少 id");
845
+ const profile = await upstream.setProfileAutoUpdate(args.id, args?.enabled !== false, Number(args?.hours) > 0 ? Number(args.hours) : undefined);
846
+ notes.push(`「${profile.label}」自动更新已${profile.autoUpdate ? `开启(每 ${profile.autoUpdateHours} 小时)` : "关闭"}。`);
847
+ return await snapshot();
848
+ }
849
+ case "reorder": {
850
+ if (!Array.isArray(args?.ids)) throw new Error("缺少 ids 数组");
851
+ await upstream.reorderProfiles(args.ids);
852
+ notes.push("顺序已保存。");
853
+ return await snapshot();
854
+ }
855
+ case "rename": {
856
+ if (typeof args?.id !== "string") throw new Error("缺少 id");
857
+ const profile = await upstream.renameProfile(args.id, String(args?.label ?? ""));
858
+ notes.push(`已改名为「${profile.label}」。`);
859
+ return await snapshot();
860
+ }
861
+ default:
862
+ throw new Error(`未知动作:${requested}`);
863
+ }
864
+ }
865
+ })
866
+ );
867
+
868
+ // ---- dsh_run_proxied ---------------------------------------------------
869
+ let delegatedSeq = 0;
870
+ disposers.push(
871
+ ctx.tools.register({
872
+ name: "dsh_run_proxied",
873
+ description:
874
+ "让 git / curl 这类 shell 下载走本插件的代理:把命令包装成「这一次调用带代理环境」再交给 shell 工具执行(沙箱与权限策略照常生效)。" +
875
+ "国内目标仍由插件的规则内核判定直连,国内 AI 平台域名直接绕过代理。execute=false 时只返回拼好的命令,交给你自己用 pwsh 跑。",
876
+ parameters: {
877
+ type: "object",
878
+ properties: {
879
+ command: { type: "string", description: "要执行的完整命令,例如 git clone https://github.com/x/y.git。" },
880
+ execute: { type: "boolean", description: "是否直接执行。默认 true;false 只返回拼好的命令。" },
881
+ shell: { type: "string", enum: ["pwsh", "bash"], description: "目标 shell,默认 pwsh(Windows)。" },
882
+ workdir: { type: "string", description: "可选工作目录,仅 execute=true 时传给 shell 工具。" },
883
+ description: { type: "string", description: "可选,写给人看的这一条命令在做什么;留空则自动生成。" }
884
+ },
885
+ required: ["command"],
886
+ additionalProperties: false
887
+ },
888
+ output: { schema: OBJECT_OUTPUT_SCHEMA, render: (_args, value) => renderProxiedRun(value) },
889
+ isConcurrencySafe: () => false,
890
+ presentCall: (args) => ({ card: "terminal", title: "带代理执行命令", description: String(args?.command ?? "").slice(0, 200) }),
891
+ presentResult: (args, result) => ({ card: "terminal", title: result?.isError === true ? "执行失败" : "执行完成" }),
892
+ execute: async (args, exec) => {
893
+ const command = String(args?.command ?? "").trim();
894
+ if (command.length === 0) throw new Error("缺少 command 参数");
895
+ const requestedShell = args?.shell === "bash" ? "bash" : "pwsh";
896
+ // 内核是懒启动的:不先 resolve 一次,刚挂载完(或刚重启)的第一次
897
+ // 调用会拿不到端口与令牌。
898
+ await upstream.resolve();
899
+ const env = upstream.shellProxyEnv();
900
+ if (env === null) {
901
+ throw new Error("代理内核还没就绪:请先在「设置 → 下载代理」里导入一份可用配置(或填 proxyUrl),然后再试。");
902
+ }
903
+ const prefix = buildShellPrefix({ shell: requestedShell, proxyUrl: env.proxyUrl, noProxy: env.noProxy });
904
+ const composed = `${prefix}${command}`;
905
+
906
+ if (args?.execute === false) {
907
+ return {
908
+ executed: false,
909
+ shell: requestedShell,
910
+ command: composed,
911
+ no_proxy: env.noProxy,
912
+ output: null,
913
+ exit_note: "把 command 的内容原样交给 shell 工具执行即可。"
914
+ };
915
+ }
916
+
917
+ const shellTool = ctx.tools.get(requestedShell) !== undefined ? requestedShell : ctx.tools.get("bash") !== undefined ? "bash" : "pwsh";
918
+ const delegated = await ctx.tools.execute({
919
+ callId: `${exec.callId}:proxied-${(delegatedSeq += 1)}`,
920
+ rootCallId: exec.rootCallId,
921
+ name: shellTool,
922
+ arguments: buildShellToolArguments({
923
+ command: composed,
924
+ description: typeof args?.description === "string" ? args.description : undefined,
925
+ workdir: typeof args?.workdir === "string" ? args.workdir : undefined
926
+ }),
927
+ agent: exec.agent,
928
+ parent: exec.token,
929
+ signal: exec.signal
930
+ });
931
+ const output = (delegated.content ?? [])
932
+ .filter((block) => block.type === "text")
933
+ .map((block) => block.text)
934
+ .join("\n");
935
+ if (delegated.isError === true) {
936
+ throw new Error(
937
+ `命令执行失败:\n${output || delegated.error?.message || "未知错误"}\n` +
938
+ "(也可以先用 execute=false 只拿拼好的命令,自己再调用 shell 工具)"
939
+ );
940
+ }
941
+ return { executed: true, shell: shellTool, command: composed, no_proxy: env.noProxy, output, exit_note: null };
942
+ }
943
+ })
944
+ );
945
+
946
+ // ---- 工具守卫:拦住没走代理的境外 shell 下载 ----------------------------
947
+ // 只拦「明确带 http(s) URL 的下载类命令」,且只在内核确实可用时才拦 ——
948
+ // 拦下来却没有可用出口比不拦更糟。命令里已经带代理的一律放行。
949
+ try {
950
+ disposers.push(
951
+ ctx.tools.guard((execution) => {
952
+ if (resolveConfig().guardShellDownloads === false) return undefined;
953
+ const toolName = String(execution?.name ?? "");
954
+ if (toolName !== "pwsh" && toolName !== "bash") return undefined;
955
+ const view = upstream.view();
956
+ if (view.engine === null || view.engine === undefined || view.available !== true) return undefined;
957
+ const command = String(execution?.arguments?.command ?? execution?.arguments?.script ?? "");
958
+ if (command.trim().length === 0) return undefined;
959
+ const verdict = classifyShellCommand(command, view.engine);
960
+ if (verdict.foreign.length === 0) return undefined;
961
+ return explainShellRouting({ foreign: verdict.foreign });
962
+ })
963
+ );
964
+ } catch (error) {
965
+ log.warn("could not register the shell-download guard: %s", String(error));
966
+ }
967
+
968
+ // ---- runtime skill -----------------------------------------------------
969
+ ctx.inject?.(['skills'], (skillsCtx) => {
970
+ try {
971
+ disposers.push(
972
+ skillsCtx.skills.register({
973
+ name: "smart-download",
974
+ source: ENTRY_ID,
975
+ whenToUse:
976
+ "当任务需要从互联网获取文件时 —— 安装包、GitHub Release 资产、模型权重、数据集、压缩包、磁盘镜像,或任何大文件;" +
977
+ "以及当有人问某个网站是否需要代理时。",
978
+ description:
979
+ "用 dsh_download 下载互联网文件:境内目标直连,境外目标走插件自带的代理内核(自包含订阅或显式 proxyUrl)," +
980
+ "直连失败会自动改用代理重试,返回值里带 sha256 与保存路径。下载互联网文件请优先用它,而不是 curl / Invoke-WebRequest。",
981
+ content: SKILL_CONTENT,
982
+ invocation: { modelInvocable: true, userInvocable: true }
983
+ })
984
+ );
985
+ log.info("registered the smart-download skill");
986
+ } catch (error) {
987
+ log.warn("could not register the smart-download skill: %s", String(error));
988
+ }
989
+ });
990
+
991
+ // ---- management panel JSON API ----------------------------------------
992
+ ctx.inject?.(['webServer'], (wsCtx) => {
993
+ try {
994
+ disposers.push(
995
+ wsCtx.webServer.register({
996
+ kind: "prefix",
997
+ path: "/dsh-downloader",
998
+ handler: (req, res) => {
999
+ upstream.handleHttp(req, res).catch((error) => {
1000
+ log.warn("management API request failed: %s", String(error));
1001
+ if (!res.headersSent) res.writeHead(500, { "content-type": "application/json; charset=utf-8" });
1002
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
1003
+ });
1004
+ }
1005
+ })
1006
+ );
1007
+ log.info("registered /dsh-downloader management API");
1008
+ } catch (error) {
1009
+ log.warn("could not register the management API route: %s", String(error));
1010
+ }
1011
+ });
1012
+
1013
+ ctx.on("dispose", () => {
1014
+ for (const dispose of disposers.splice(0)) {
1015
+ try {
1016
+ dispose();
1017
+ } catch {
1018
+ /* already disposed */
1019
+ }
1020
+ }
1021
+ void upstream.stop();
1022
+ });
1023
+
1024
+ log.info("loaded (%d config fields)", CONFIG_KEYS.length);
1025
+
1026
+ // 异步预热一次,让内核与规则引擎在挂载后很快可用 —— 守卫是同步的,只有
1027
+ // 内核已解析时才能判定境内外。不 await:慢订阅不该拖住插件挂载。
1028
+ void upstream.resolve().catch((error) => log.warn("initial upstream resolution failed: %s", String(error)));
1029
+ }
1030
+
1031
+ /** Render a session-guard report. */
1032
+ function renderGuard(value) {
1033
+ if (value === null || typeof value !== "object") return text(value);
1034
+ const lines = [
1035
+ `动作:${actionLabel(value.action)}`,
1036
+ `配置文件:${shown(value.env_path)}${value.exists === false ? "(尚未创建)" : ""}`,
1037
+ `该文件里的全局代理变量:${Array.isArray(value.proxy_vars) && value.proxy_vars.length > 0 ? value.proxy_vars.join("、") : "无"}`,
1038
+ `NO_PROXY:${value.no_proxy && value.no_proxy.length > 0 ? value.no_proxy : "(未设置)"}`,
1039
+ `AI 平台强制直连:${value.protected_domain_count} 个域名 —— ${value.covered === true ? "已全部写入 NO_PROXY" : `还缺 ${value.missing_count} 个`}`
1040
+ ];
1041
+ if (value.changed === true) {
1042
+ lines.push(`已写入合并后的 NO_PROXY,新增 ${value.added_count} 条`);
1043
+ if (value.backup_path) lines.push(`备份:${value.backup_path}`);
1044
+ } else if (value.changed === false) {
1045
+ lines.push("无需改动");
1046
+ }
1047
+ if (value.restored === true) lines.push(`已从备份 ${shown(value.backup_path)} 还原`);
1048
+ if (value.no_proxy_line && value.changed !== false) lines.push(`目标行:${value.no_proxy_line}`);
1049
+ if (Array.isArray(value.notes)) lines.push(...value.notes);
1050
+ return text(lines.join("\n"));
1051
+ }
1052
+
1053
+ /**
1054
+ * Offline-first routing verdict for one target.
1055
+ * @param input - URL or bare host.
1056
+ * @param options - `{ ping, upstream, exec }`.
1057
+ */
1058
+ async function geoCheck(input, { ping = true, upstream, exec }) {
1059
+ let host = input;
1060
+ try {
1061
+ const url = new URL(input.includes("://") ? input : `http://${input}`);
1062
+ host = url.hostname;
1063
+ } catch {
1064
+ // Treat it as a bare host.
1065
+ }
1066
+ const literal = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.includes(":");
1067
+
1068
+ const view = await upstream.resolve();
1069
+ let route = "direct";
1070
+ let reason = "";
1071
+ if (view.engine === null || view.engine === undefined) {
1072
+ reason = view.reason ?? "没有配置代理,下载会直连";
1073
+ } else {
1074
+ const decision = view.engine.decide(host);
1075
+ route = decision === "reject" ? "reject" : decision;
1076
+ reason =
1077
+ decision === "direct"
1078
+ ? `内置规则库把 ${host} 判为境内/直连`
1079
+ : decision === "reject"
1080
+ ? `当前规则拒绝访问 ${host}`
1081
+ : `内置规则库把 ${host} 判为境外/走代理`;
1082
+ }
1083
+
1084
+ const ips = literal ? [host] : await resolveHost(host);
1085
+ const provisional = ips.filter((ip) => !isPrivateIp(ip));
1086
+ const allPrivate = ips.length > 0 && provisional.length === 0;
1087
+
1088
+ // Only reach for the network when the offline tables could not decide.
1089
+ let geo = null;
1090
+ let geoSource = null;
1091
+ if (allPrivate && ips.length > 0) {
1092
+ geoSource = "内网地址";
1093
+ } else {
1094
+ const target = provisional[0] ?? ips[0] ?? null;
1095
+ if (target !== null && !literal) {
1096
+ geo = await geoLookupIP(target, { timeoutMs: 6000, signal: exec?.signal });
1097
+ if (geo !== null) geoSource = `在线 GeoIP(${geo.provider})`;
1098
+ }
1099
+ }
1100
+
1101
+ let pingResult = null;
1102
+ if (ping && ips.length > 0) {
1103
+ const [icmp, tcp] = await Promise.all([icmpPing(host), tcpPing(host, 443)]);
1104
+ pingResult = { icmp: { ok: icmp.ok, time_ms: icmp.timeMs }, tcp_443: { ok: tcp.ok, time_ms: tcp.timeMs } };
1105
+ }
1106
+
1107
+ let advice;
1108
+ if (route === "proxy") {
1109
+ advice = view.available ? "该主机的下载会走代理内核。" : "该主机需要走代理,但没有可用节点 —— 请检查「设置 → 下载代理」。";
1110
+ } else if (route === "direct") {
1111
+ advice = "该主机的下载会直连,不经过任何代理。";
1112
+ } else {
1113
+ advice = "当前规则拒绝该主机,下载会按设计失败。";
1114
+ }
1115
+ if (allPrivate && !literal) {
1116
+ advice += ` 该域名解析到 ${ips.join("、")},看起来是 hosts 改写或 DNS 污染。`;
1117
+ }
1118
+
1119
+ return {
1120
+ input,
1121
+ host,
1122
+ route,
1123
+ reason,
1124
+ resolved_ips: ips,
1125
+ geo: geo === null ? null : { ip: geo.ip, country: geo.country, country_code: geo.countryCode, isp: geo.isp, provider: geo.provider },
1126
+ geo_source: geoSource,
1127
+ proxy_available: view.available === true,
1128
+ proxy_mode: view.mode,
1129
+ ping: pingResult,
1130
+ advice
1131
+ };
1132
+ }
1133
+
1134
+ /** Body of the registered `smart-download` runtime skill. */
1135
+ const SKILL_CONTENT = `# 智能下载
1136
+
1137
+ 本插件注册了四个工具。下载互联网文件请用它们,**不要**去 shell 里跑 \`curl\` / \`Invoke-WebRequest\` ——
1138
+ 那样完全绕开了插件的分流规则。
1139
+
1140
+ | 工具 | 用途 |
1141
+ | --- | --- |
1142
+ | \`dsh_download\` | 下载任何互联网文件。路由分流、大小上限、sha256、失败回退都已内置。 |
1143
+ | \`dsh_proxy_status\` | 排查「为什么走了 / 没走代理」。 |
1144
+ | \`dsh_geo_check\` | 回答「这个站是国内还是国外、会不会走代理」。 |
1145
+ | \`dsh_session_guard\` | 检查或修复 \`$DSH_HOME/.env\` 里的 \`NO_PROXY\`,让会话自身的模型连接不依赖代理。 |
1146
+ | \`dsh_profile\` | 管理节点配置(FlClash 式):列出 / 从订阅地址或 \`clash://\` 深链导入 / 从配置正文导入 / 更新 / 选择 / 删除 / 开关自动更新 / 排序 / 改名。 |
1147
+ | \`dsh_run_proxied\` | 让 \`git\` / \`curl\` 这类 shell 下载走本插件的代理(只作用于这一次调用)。 |
1148
+
1149
+ ## git / curl 下载境外内容
1150
+
1151
+ **要跑 \`git clone\` / \`git pull\` / \`curl\` / \`wget\` 去境外取东西时,用 \`dsh_run_proxied\`**,不要直接调 shell 工具:
1152
+
1153
+ \`\`\`
1154
+ dsh_run_proxied({ command: "git clone https://github.com/x/y.git" })
1155
+ \`\`\`
1156
+
1157
+ 它会为这一次 shell 调用注入代理环境,国内目标仍由插件内核判定直连,国内 AI 平台域名直接绕过。
1158
+ 如果只是下一个文件,**优先用 \`dsh_download\`**,它带 sha256、大小上限与失败回退。
1159
+
1160
+ 插件还注册了一条工具守卫:当 shell 命令看起来要从境外下载、又没有带代理时,会被拦下来并提示你改用
1161
+ \`dsh_run_proxied\`。命令里已经带了代理、或者内核没有可用出口时不会被拦。
1162
+
1163
+ ## 工作约定
1164
+
1165
+ 1. **要下载文件就直接调 \`dsh_download\`。** 不要先跑 \`dsh_geo_check\` —— 下载工具内部已经做了路由判定;
1166
+ \`dsh_geo_check\` 是用来回答问题的,不是前置步骤。
1167
+ 2. **不要用 \`curl\` / \`Invoke-WebRequest\` 代替**,除非用户明确要求 shell 命令。它们会完全绕开代理规则。
1168
+ 3. **路由是自动的,而且会如实上报。** 读返回值里的 \`route\` 与 \`route_reason\`:
1169
+ - \`direct\` —— 境内目标,不经过代理。
1170
+ - \`proxy\` —— 境外目标,经插件的节点下载。
1171
+ - \`fallback_used: true\` —— 直连失败,改用代理重试成功。
1172
+ 4. **如实汇报事实**:\`saved_to\`、\`bytes\`(转成人类可读)、\`sha256\`、\`speed_bps\`;用户关心时提一句路由。
1173
+ 5. **大文件默认就是多线程。** 服务端支持 \`Range\` 且文件 ≥ 1 MiB 时自动用 \`threads\`(默认 4)个分片并发,
1174
+ 先完成的连接去偷剩余最多的分片;不支持 Range 时自动回退单连接。返回值里的 \`via_threads\` / \`segments\`
1175
+ 说明实际走了哪条路。想换线程数就传 \`threads\`(1 = 强制单连接)。
1176
+ 5. **撞到大小上限先确认再改。** \`max_mb\` 默认 512。工具报「超过上限」时,把服务端声明的大小告诉用户、
1177
+ 确认之后再提高 \`max_mb\`。
1178
+ 6. **没有可用代理出口时,绝不要偷偷退回 \`curl\`。** 告诉用户去 **设置 → 下载代理** 填订阅地址或 proxyUrl,然后重试。
1179
+ 7. **取消 / 停滞的下载不留半截文件**:临时文件总会被删掉,重跑永远安全。
1180
+ 8. **大文件不受宿主超时约束**,但仍受 \`download_timeout_s\`(默认 600 秒)约束。预计会久得多的,
1181
+ 先跟用户确认,再在调用里传更大的 \`timeout_seconds\`。
1182
+ 9. **节点抖动绝不能打断会话。** 国内 AI 平台域名(DeepSeek、智谱/Z.ai、Kimi、通义、豆包/方舟、文心、混元、
1183
+ 星火、MiniMax、零一万物、阶跃、商汤、百川、硅基流动、天工、盘古……)被钉在最高优先级的 DIRECT 规则上,
1184
+ 对它们的下载不会被路由到节点,**即使 \`domesticDirect\` 关掉也一样**。
1185
+ 如果用户反馈「代理一断会话就死」:先跑 \`dsh_session_guard\` 的 \`action="check"\`,说明清楚
1186
+ **Harness 自身的模型流量由 \`$DSH_HOME/.env\` 决定、不受本插件管辖**,再提议 \`action="apply"\`
1187
+ (它会先备份该文件,且绝不动 \`HTTP_PROXY\` / \`HTTPS_PROXY\`)。记得提醒:那个文件要**重启 DSH** 才生效。
1188
+
1189
+ ## 覆盖范围的边界(用户问起时要说清楚)
1190
+
1191
+ 本插件**只管它自己的下载**。\`web_fetch\`,以及 shell 工具里的 \`curl\` / \`git\` / \`npm\`,
1192
+ 由 DSH 启动期的代理策略(\`$DSH_HOME/.env\` 里的 \`HTTP_PROXY\` / \`HTTPS_PROXY\`)决定,不归本插件管。
1193
+ 用户需要那些也走代理时,让他去改那个文件。
1194
+ `;
1195
+
1196
+ export { Config, CONFIG_KEYS, ENTRY_ID, SETTINGS_NAMESPACE, apply, inject, name };
1197
+
1198
+ /** Renderers and helpers exposed for the offline test suite only. */
1199
+ export const __internals = { plain, renderDownload, renderGeo, renderGuard, renderProfiles, renderProxiedRun, renderStatus, shown, AI_DIRECT_DOMAINS };