@eddyskywalker/dsh-chatgpt-subscription 0.2.16 → 0.2.17

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 CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## Unreleased
4
+
5
+ - 修复开启系统代理的机器上 `web_fetch` 必然失败的问题:DSH 内置抓取 provider 在连接前解析、校验并固定目标地址,而代理工具(Clash/Mihomo 等)的 fake-ip DNS 会把域名解析成 `198.18.0.0/15` 里的保留地址(实测 `api.github.com` → `198.18.0.17`),于是每次调用都以 `WEB_BLOCKED_URL`(`resolves to a non-public IP address`)结束——代理根本没被用上。DSH 只在进程环境变量里读到代理时才走代理,看不到系统代理。现在只要插件配置了可用代理(系统代理自动检测或自定义代理),`web_fetch` 就改用本插件的抓取 provider:由代理解析源站,与 DSH 对“走代理的请求”采用的语义一致;未配置代理时仍由内置 provider 抓取,其解析与固定策略不变。
6
+ - 抓取 provider 新增地址策略 `src/host/fetch-address-policy.ts`,保留内置 provider 安全边界中不需要 DNS 的那一半:URL 里写明的 IP 字面量只有全球可路由单播才放行(loopback、私网、链路本地、CGNAT、多播、保留地址、IPv6 转换与隧道前缀一律拒绝);域名用本机解析器检查一次,落在私网(含 `localhost`、`127.0.0.1.nip.io` 这类)一律拒绝;只有代理的 fake-ip 答案被接受,本机解析不出的域名交给代理处理。与内置 provider 的差别是不再固定(pin)连接地址——fake-ip 环境下这一步无法成立,已在 README 的安全边界中写明。
7
+ - 代理偏好在运行时变化(例如系统代理 ↔ 直连)会重新选择抓取后端;选择 ChatGPT 搜索来源时同时切换搜索与抓取的行为保持不变。
8
+ - 新增 `test/fetch-address-policy.test.ts`(地址分类、fake-ip 识别、私网解析拒绝、解析失败放行),并扩充 `test/codex-fetch.test.ts`、`test/search-provider-switcher.test.ts`、`test/web-provider-lifecycle.test.ts` 覆盖抓取 provider 的拒绝路径与后端切换。
9
+
3
10
  ## 0.2.15 - 2026-09-11
4
11
 
5
12
  - 新增子代理模型授权守卫:DSH 的「Subagent」允许列表(`subagent-model-selection`)原本只在模型**显式**填写 `provider`/`model` 时生效,而委派调用不填路由时子代理会继承父级模型——于是 DeepSeek 会话里的子代理仍会在未授权模型上运行。插件现在在 Host 工具注册表上注册一个单调守卫(`ctx.tools.guard`),当调用会话(或最近的、记录了策略的祖先会话)带有允许列表时,任何生效路由不在此列表内的委派都会在子代理启动前被拒绝,拒绝理由里带上全部已授权路由,模型据此重试即可选中合规模型。
package/README.md CHANGED
@@ -121,7 +121,9 @@ DSH 模型选择器应显示 **“Codex(ChatGPT 订阅)”**。6 Astra 与 G
121
121
 
122
122
  **设置 → Codex 订阅 → 网络代理** 同时控制 GPT 与 Antigravity(Gemini)的 Host 请求,可选择系统代理(自动检测)、自定义代理或直连。Gemini 模型生成、网页登录后的令牌交换、令牌刷新、账号信息、项目发现、配额与模型目录查询均使用此设置;修改后对后续请求生效,无需重启 DSH。浏览器中的 Google 授权页面使用浏览器自己的网络设置。
123
123
 
124
- **搜索与抓取来源** 同时切换 DSH 的搜索和网页抓取后端。选择 ChatGPT 来源后,网页由本插件在 Host 抓取,并使用上述网络代理设置;纯 TUN 模式可使用直连,流量由虚拟网卡接管。来源切换即时生效,DSH web 服务重载后会重新注册插件后端,并保留切回 DSH 默认来源所需的配置。选择 DSH 默认来源时,抓取仍遵循 DSH 自身的代理及非公网地址校验策略。
124
+ **搜索与抓取来源** 切换 DSH 的搜索后端;网页抓取后端在有可用代理时自动改用本插件。选择 ChatGPT 来源后,网页由本插件在 Host 抓取,并使用上述网络代理设置;纯 TUN 模式可使用直连,流量由虚拟网卡接管。来源切换即时生效,DSH web 服务重载后会重新注册插件后端,并保留切回 DSH 默认来源所需的配置。
125
+
126
+ **网页抓取后端(web_fetch)** DSH 内置抓取 provider 会先解析域名、校验并固定解析结果,而且只在进程环境变量里读到代理时才走代理——系统代理对它不可见。代理工具(Clash/Mihomo 等)常把域名解析成自己的 fake-ip 地址(默认 `198.18.0.0/15`),于是内置 provider 直接以 `WEB_BLOCKED_URL`(resolves to a non-public IP address)拒绝,代理根本没被用上。因此只要插件配置了可用代理(**网络代理** 选系统代理且检测到,或填写自定义代理),`web_fetch` 就改用本插件的 provider:由代理解析源站,与 DSH 对“走代理的请求”采用的语义一致;未配置代理时仍由 DSH 内置 provider 抓取,保留其解析与固定策略。若在纯 TUN 模式下把代理设为**直连**,内置 provider 会重新接管,此时可改回系统代理让插件接管抓取。
125
127
 
126
128
  卸载前建议先在设置页点击 **“注销”**,它会删除当前平台的凭据和 Host 内存中的额度缓存。
127
129
 
@@ -170,6 +172,7 @@ Antigravity 的 Gemini 用量以流结束时的上游累计计数为准,缓存
170
172
  - Windows token bundle 使用 CurrentUser DPAPI;明文只经过 Host 内存和 PowerShell stdin/stdout;
171
173
  - macOS token bundle 存入登录钥匙串,通过系统 `security` 命令读写;明文只经过 Host 内存和 `security` 命令行参数,Keychain 在本机加密保存;
172
174
  - Linux token bundle 原子写入当前用户私有文件并在读取时校验普通文件、所有者与 `0600` 权限,同时拒绝符号链接;该文件没有应用层加密,同 UID 进程、root、备份和磁盘快照仍可读取;
175
+ - 本插件抓取 provider 的地址策略:URL 里直接写明的 IP 字面量只有在全球可路由单播时才放行(loopback、私网、链路本地、CGNAT、多播、保留地址、IPv6 转换与隧道前缀一律拒绝);域名会先用本机解析器检查一次,解析结果落在私网时同样拒绝,只有代理的 fake-ip 地址(`198.18.0.0/15`)被接受——那正是本机代理声称拥有该域名的表现;本机解析不出的域名交给代理处理。与 DSH 内置 provider 的差别是不固定(pin)连接地址,因为 fake-ip 环境下这一步无法成立;
173
176
  - 所有平台的 token 都不会进入浏览器、`settings.yaml` 或日志;
174
177
  - 工具调用只有在参数是完整 JSON 对象时才产生可执行的 `block-end`;畸形参数终止本次生成;
175
178
  - 所有修改状态的路由只接受同源 JSON POST,并校验 `Origin` 与 `Host`。
@@ -212,6 +215,7 @@ npm pack --dry-run
212
215
  | 额度显示旧数据 | 设置页会保留最后成功值;等待 15 秒节流窗口后手动刷新 |
213
216
  | 429 | 插件遵守 `Retry-After`,不会高频轮询;模型请求由 DSH retry policy 有界重试 |
214
217
  | 模型不可用 | 检查 ChatGPT 套餐、workspace 权限与当前模型可用性 |
218
+ | `web_fetch` 报 `resolves to a non-public IP address` | 机器启用了系统代理(fake-ip DNS),但插件没有可用代理可接管抓取:在 **设置 → Codex 订阅 → 网络代理** 选择系统代理(自动检测)或填写自定义代理 |
215
219
  | DPAPI 读取失败 | 确认 DSH 以创建凭据时的同一 Windows 用户运行;必要时清理凭据后重新登录 |
216
220
  | Linux 凭据存储不可用 | 确认凭据属于当前用户且权限为 `0600`,父目录权限为 `0700`;修复权限或注销后重新登录 |
217
221
  | Linux 上工具调用语法错误 | 确认 DSH 暴露的是 `bash`、`sh` 或 `shell`,并使用相应的 Bash/POSIX 语法与 `/` 路径 |
package/lib/index.js CHANGED
@@ -3,6 +3,8 @@ import z from "@deepseek-ai/schemastery";
3
3
  import * as LlmModule from "@deepseek-ai/dsh-llm";
4
4
  import { HarnessError, LlmAdapter, LlmError, ProviderRequestId, ReasoningEffortId, attributionHeaders, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
5
  import { WebError } from "@deepseek-ai/dsh-web";
6
+ import { lookup } from "node:dns/promises";
7
+ import { isIP } from "node:net";
6
8
  import { createUserMessage } from "@deepseek-ai/dsh-llm/message";
7
9
  import { defineTool } from "@deepseek-ai/dsh-tools";
8
10
  import { createHash, randomBytes, randomUUID } from "node:crypto";
@@ -274,6 +276,225 @@ var CodexChatGptAdapter = class extends LlmAdapter {
274
276
  }
275
277
  };
276
278
  //#endregion
279
+ //#region src/host/fetch-address-policy.ts
280
+ /**
281
+ * Destination policy for this plugin's fetch provider.
282
+ *
283
+ * DSH's built-in provider resolves every destination and validates and pins the
284
+ * address before it connects. That cannot work on a machine whose proxy answers
285
+ * DNS with its own fake-ip range (`198.18.0.0/15` for Clash/Mihomo, the usual
286
+ * companion of a system proxy): the hostname is ordinary, the answer is reserved
287
+ * space, and every request is refused before the proxy that could have resolved
288
+ * it is ever consulted. This provider exists for that machine, so it cannot
289
+ * validate names the way the built-in one does.
290
+ *
291
+ * It keeps the half of the policy that needs no resolution — an address the URL
292
+ * states outright is refused unless it is globally reachable unicast, because a
293
+ * proxy on this machine must not become a path into loopback or a LAN — and it
294
+ * also refuses a name this machine resolves into private space. Only the proxy's
295
+ * own fake-ip answers are accepted, since that is what the proxy's name
296
+ * resolution looks like from here; the URL hostname still reaches the proxy
297
+ * intact, so the proxy decides where the name really goes.
298
+ *
299
+ * @module dsh-chatgpt-subscription/fetch-address-policy
300
+ */
301
+ /**
302
+ * Every IPv4 range a fetch may not target: "this network", private space,
303
+ * carrier-grade NAT, loopback, link-local, IETF assignments, documentation,
304
+ * the 6to4 relay anycast, the benchmarking range the mainstream proxy tools use
305
+ * for fake-ip answers, multicast, and reserved space including the broadcast
306
+ * address.
307
+ */
308
+ const NON_PUBLIC_IPV4 = [
309
+ "0.0.0.0/8",
310
+ "10.0.0.0/8",
311
+ "100.64.0.0/10",
312
+ "127.0.0.0/8",
313
+ "169.254.0.0/16",
314
+ "172.16.0.0/12",
315
+ "192.0.0.0/24",
316
+ "192.0.2.0/24",
317
+ "192.88.99.0/24",
318
+ "192.168.0.0/16",
319
+ "198.18.0.0/15",
320
+ "198.51.100.0/24",
321
+ "203.0.113.0/24",
322
+ "224.0.0.0/4",
323
+ "240.0.0.0/4"
324
+ ];
325
+ /** The fake-ip range the mainstream proxy tools hand out for the names they own. */
326
+ const FAKE_IP_RANGE = "198.18.0.0/15";
327
+ /**
328
+ * Resolve one hostname to every address the local resolver returns, or to no
329
+ * addresses when it cannot.
330
+ *
331
+ * A failure is not evidence of a local destination: the configured proxy
332
+ * resolves the origin itself, so a name this machine cannot resolve must not
333
+ * fail a request the proxy could have served.
334
+ *
335
+ * @param hostname - the URL hostname, without brackets.
336
+ * @returns the resolved addresses, or an empty list when resolution failed.
337
+ */
338
+ async function lookupHostAddresses(hostname) {
339
+ try {
340
+ return (await lookup(hostname, {
341
+ all: true,
342
+ verbatim: true
343
+ })).map((answer) => answer.address);
344
+ } catch {
345
+ return [];
346
+ }
347
+ }
348
+ /**
349
+ * Whether a host is stated as an IP address rather than as a name.
350
+ *
351
+ * @param hostname - a URL hostname, bracketed or not.
352
+ * @returns true when the host is an IPv4 or IPv6 literal.
353
+ */
354
+ function isIpLiteral(hostname) {
355
+ return isIP(unbracket(hostname)) !== 0;
356
+ }
357
+ /**
358
+ * Whether an address is one only a proxy's fake-ip resolver hands out. The IANA
359
+ * benchmarking range carries no real host, so an answer inside it is this
360
+ * machine's proxy claiming the name, not a private destination.
361
+ *
362
+ * @param address - a textual IPv4 (or IPv4-mapped IPv6) address.
363
+ * @returns true when the address is inside the proxy fake-ip range.
364
+ */
365
+ function isProxyFakeIpAddress(address) {
366
+ const value = ipv4Value(unbracket(address).replace(/^::ffff:/i, ""));
367
+ return value !== void 0 && inRange(value, FAKE_IP_RANGE);
368
+ }
369
+ /**
370
+ * Whether an address is globally reachable unicast. IPv4-mapped IPv6 is
371
+ * classified by the IPv4 address it embeds; every other IPv6 address is public
372
+ * only inside `2000::/3`, which excludes loopback, unique-local, link-local,
373
+ * multicast, and the transition and translation prefixes whose real destination
374
+ * cannot be seen from the address alone.
375
+ *
376
+ * @param address - a textual IPv4 or IPv6 address, bracketed or not.
377
+ * @returns true only for a public unicast destination.
378
+ */
379
+ function isPublicIpAddress(address) {
380
+ const unbracketed = unbracket(address);
381
+ const family = isIP(unbracketed);
382
+ if (family === 4) return isPublicIpv4(unbracketed);
383
+ if (family !== 6) return false;
384
+ const mapped = unbracketed.replace(/^::ffff:/i, "");
385
+ if (isIP(mapped) === 4) return isPublicIpv4(mapped);
386
+ return isPublicIpv6(unbracketed);
387
+ }
388
+ /**
389
+ * Whether a hostname is an IP literal no request may target.
390
+ *
391
+ * @param hostname - a URL hostname, bracketed or not.
392
+ * @returns true when the host is a literal address that is not public.
393
+ */
394
+ function isNonPublicIpLiteral(hostname) {
395
+ const unbracketed = unbracket(hostname);
396
+ return isIP(unbracketed) !== 0 && !isPublicIpAddress(unbracketed);
397
+ }
398
+ /**
399
+ * Refuse a fetch destination the local network policy exists to keep out of
400
+ * reach, before any request is made.
401
+ *
402
+ * @param hostname - the URL hostname, bracketed or not.
403
+ * @param addresses - every address the local resolver returned for the host.
404
+ * @throws WebError `WEB_BLOCKED_URL` for a non-public literal, or for a name
405
+ * this machine resolves into private space that is not the proxy's fake-ip.
406
+ */
407
+ function assertPublicFetchTarget(hostname, addresses) {
408
+ if (isNonPublicIpLiteral(hostname)) throw new WebError(`URL hostname "${hostname}" is not a public IP address`, "WEB_BLOCKED_URL");
409
+ for (const address of addresses) {
410
+ if (isPublicIpAddress(address) || isProxyFakeIpAddress(address)) continue;
411
+ throw new WebError(`URL hostname "${hostname}" resolves to a non-public IP address`, "WEB_BLOCKED_URL");
412
+ }
413
+ }
414
+ /** Whether a dotted quad is public, using {@link NON_PUBLIC_IPV4}. */
415
+ function isPublicIpv4(address) {
416
+ const value = ipv4Value(address);
417
+ if (value === void 0) return false;
418
+ return !NON_PUBLIC_IPV4.some((cidr) => inRange(value, cidr));
419
+ }
420
+ /** Whether a 32-bit address sits inside one `a.b.c.d/prefix` range. */
421
+ function inRange(value, cidr) {
422
+ const slash = cidr.indexOf("/");
423
+ const network = ipv4Value(cidr.slice(0, slash));
424
+ const prefix = Number(cidr.slice(slash + 1));
425
+ if (network === void 0 || !Number.isInteger(prefix) || prefix < 0 || prefix > 32) return false;
426
+ const mask = prefix === 0 ? 0 : 4294967295 << 32 - prefix >>> 0;
427
+ return (value & mask) >>> 0 === (network & mask) >>> 0;
428
+ }
429
+ /** Parse a dotted quad into its 32-bit value, or `undefined` when it is not one. */
430
+ function ipv4Value(address) {
431
+ const parts = address.split(".");
432
+ if (parts.length !== 4) return void 0;
433
+ let value = 0;
434
+ for (const part of parts) {
435
+ if (!/^\d{1,3}$/.test(part)) return void 0;
436
+ const octet = Number(part);
437
+ if (octet > 255) return void 0;
438
+ value = (value << 8 | octet) >>> 0;
439
+ }
440
+ return value;
441
+ }
442
+ /** Whether an IPv6 literal is inside `2000::/3` and outside its blocked sub-ranges. */
443
+ function isPublicIpv6(address) {
444
+ const words = ipv6Words(address);
445
+ if (words === void 0) return false;
446
+ const [first = 0, second = 0] = words;
447
+ if ((first >> 8 & 224) !== 32) return false;
448
+ if (first === 8193) {
449
+ if (second === 3512) return false;
450
+ if (second === 0) return false;
451
+ if (second === 2) return false;
452
+ if ((second & 65520) === 16) return false;
453
+ if ((second & 65520) === 32) return false;
454
+ }
455
+ if (first === 8194) return false;
456
+ if (first === 16383 && second <= 4095) return false;
457
+ return true;
458
+ }
459
+ /** Parse an IPv6 literal into its eight 16-bit words, or `undefined` when it is not one. */
460
+ function ipv6Words(address) {
461
+ const zone = address.indexOf("%");
462
+ const halves = (zone === -1 ? address : address.slice(0, zone)).split("::");
463
+ if (halves.length > 2) return void 0;
464
+ const head = parseIpv6Half(halves[0] ?? "");
465
+ if (head === void 0) return void 0;
466
+ if (halves.length === 1) return head.length === 8 ? head : void 0;
467
+ const tail = parseIpv6Half(halves[1] ?? "");
468
+ if (tail === void 0) return void 0;
469
+ const gap = 8 - head.length - tail.length;
470
+ if (gap < 1) return void 0;
471
+ return [
472
+ ...head,
473
+ ...new Array(gap).fill(0),
474
+ ...tail
475
+ ];
476
+ }
477
+ /** Parse one '::'-free half of an IPv6 literal, expanding a trailing dotted quad. */
478
+ function parseIpv6Half(segment) {
479
+ if (segment === "") return [];
480
+ const words = [];
481
+ for (const piece of segment.split(":")) {
482
+ if (piece.includes(".")) {
483
+ const value = ipv4Value(piece);
484
+ if (value === void 0) return void 0;
485
+ words.push(value >>> 16, value & 65535);
486
+ continue;
487
+ }
488
+ if (!/^[0-9a-fA-F]{1,4}$/.test(piece)) return void 0;
489
+ words.push(parseInt(piece, 16));
490
+ }
491
+ return words;
492
+ }
493
+ /** WHATWG URL keeps brackets around an IPv6 hostname; IP parsers do not. */
494
+ function unbracket(hostname) {
495
+ return hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
496
+ }
497
+ //#endregion
277
498
  //#region src/host/codex-fetch.ts
278
499
  const DEFAULT_MAX_RESPONSE_BYTES = 2 * 1024 * 1024;
279
500
  const DEFAULT_MAX_BODY_CHARS = 1e5;
@@ -281,6 +502,7 @@ function createCodexFetchProvider(options = {}) {
281
502
  const fetchFn = options.fetchFn ?? fetch;
282
503
  const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
283
504
  const maxBodyChars = options.maxBodyChars ?? DEFAULT_MAX_BODY_CHARS;
505
+ const resolveHostAddresses = options.resolveHostAddresses ?? lookupHostAddresses;
284
506
  return {
285
507
  id: CODEX_FETCH_PROVIDER_ID,
286
508
  available: () => true,
@@ -293,6 +515,7 @@ function createCodexFetchProvider(options = {}) {
293
515
  throw new WebError(`invalid URL: ${targetUrl}`, "WEB_INVALID_URL", { cause: error });
294
516
  }
295
517
  if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") throw new WebError(`unsupported URL scheme "${parsedUrl.protocol}" (only http and https are allowed)`, "WEB_INVALID_URL");
518
+ assertPublicFetchTarget(parsedUrl.hostname, isIpLiteral(parsedUrl.hostname) ? [] : await resolveHostAddresses(parsedUrl.hostname));
296
519
  let response;
297
520
  try {
298
521
  response = await fetchFn(targetUrl, {
@@ -3538,7 +3761,7 @@ var SearchProviderSwitcher = class {
3538
3761
  constructor(loader) {
3539
3762
  this.loader = loader;
3540
3763
  }
3541
- async select(preference) {
3764
+ async select(preference, options = {}) {
3542
3765
  const entry = this.findWebEntry();
3543
3766
  if (entry === null) return;
3544
3767
  const config = currentConfig(entry);
@@ -3547,9 +3770,9 @@ var SearchProviderSwitcher = class {
3547
3770
  this.originalFetchProvider = typeof config.fetchProvider === "string" && config.fetchProvider !== "codex-subscription" ? config.fetchProvider : void 0;
3548
3771
  this.initialized = true;
3549
3772
  }
3550
- const selected = preference === "codex" ? CODEX_SEARCH_PROVIDER_ID : void 0;
3551
- const nextSearch = selected ?? this.originalSearchProvider;
3552
- const nextFetch = selected ? CODEX_FETCH_PROVIDER_ID : this.originalFetchProvider;
3773
+ const codexSelected = preference === SEARCH_PROVIDER_CODEX;
3774
+ const nextSearch = codexSelected ? CODEX_SEARCH_PROVIDER_ID : this.originalSearchProvider;
3775
+ const nextFetch = codexSelected || options.pluginFetch === true ? CODEX_FETCH_PROVIDER_ID : this.originalFetchProvider;
3553
3776
  if (config.searchProvider === nextSearch && config.fetchProvider === nextFetch) return;
3554
3777
  const nextConfig = { ...config };
3555
3778
  if (nextSearch === void 0) delete nextConfig.searchProvider;
@@ -5958,9 +6181,10 @@ function apply(ctx, pluginConfig = {}) {
5958
6181
  reasoningSummary: () => preferences.status().reasoningSummary
5959
6182
  }), preferences);
5960
6183
  const searchSwitcher = new SearchProviderSwitcher(ctx.loader);
5961
- const applySearchPreference = (searchProvider = preferences.status().searchProvider) => {
5962
- searchSwitcher.select(searchProvider).catch((error) => {
5963
- ctx.logger.warn(`[dsh-chatgpt-subscription] Search provider preference could not be applied: ${error instanceof Error ? error.message : String(error)}`);
6184
+ const applyWebProviders = (current = preferences.status()) => {
6185
+ const pluginFetch = proxyManager.resolveActiveProxyUrl() !== null;
6186
+ searchSwitcher.select(current.searchProvider, { pluginFetch }).catch((error) => {
6187
+ ctx.logger.warn(`[dsh-chatgpt-subscription] Web provider selection could not be applied: ${error instanceof Error ? error.message : String(error)}`);
5964
6188
  });
5965
6189
  };
5966
6190
  const disposeRoutes = registerRoutes(ctx, oauth, usage, preferences, proxyManager);
@@ -5969,9 +6193,9 @@ function apply(ctx, pluginConfig = {}) {
5969
6193
  ctx.inject(["web"], (ctx) => {
5970
6194
  ctx.web.registerSearchProvider(createCodexSearchProvider(oauth, { fetchFn: proxyFetch }));
5971
6195
  ctx.web.registerFetchProvider(createCodexFetchProvider({ fetchFn: proxyFetch }));
5972
- applySearchPreference();
6196
+ applyWebProviders();
5973
6197
  });
5974
- const disposePreferenceWatch = preferences.watch((next) => applySearchPreference(next.searchProvider));
6198
+ const disposePreferenceWatch = preferences.watch((next) => applyWebProviders(next));
5975
6199
  return () => {
5976
6200
  disposePreferenceWatch();
5977
6201
  disposeImageTool();
@@ -1,9 +1,16 @@
1
1
  import { type WebFetchProvider } from '@deepseek-ai/dsh-web';
2
2
  type FetchLike = typeof fetch;
3
+ /** Resolves a hostname to every address this machine's resolver returns. */
4
+ export type HostAddressResolver = (hostname: string) => Promise<readonly string[]>;
3
5
  export interface CodexFetchProviderOptions {
4
6
  fetchFn?: FetchLike;
5
7
  maxResponseBytes?: number;
6
8
  maxBodyChars?: number;
9
+ /**
10
+ * Address lookup behind the destination policy. Overridden only by focused
11
+ * tests; the policy itself is what every request goes through.
12
+ */
13
+ resolveHostAddresses?: HostAddressResolver;
7
14
  }
8
15
  export declare function createCodexFetchProvider(options?: CodexFetchProviderOptions): WebFetchProvider;
9
16
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"codex-fetch.d.ts","sourceRoot":"","sources":["../../../src/host/codex-fetch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,gBAAgB,EAA6C,MAAM,sBAAsB,CAAA;AAGjH,KAAK,SAAS,GAAG,OAAO,KAAK,CAAA;AAE7B,MAAM,WAAW,yBAAyB;IACxC,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,YAAY,CAAC,EAAE,MAAM,CAAA;CACtB;AAKD,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,gBAAgB,CAqFlB"}
1
+ {"version":3,"file":"codex-fetch.d.ts","sourceRoot":"","sources":["../../../src/host/codex-fetch.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,KAAK,gBAAgB,EAA6C,MAAM,sBAAsB,CAAA;AAIjH,KAAK,SAAS,GAAG,OAAO,KAAK,CAAA;AAE7B,4EAA4E;AAC5E,MAAM,MAAM,mBAAmB,GAAG,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAAA;AAElF,MAAM,WAAW,yBAAyB;IACxC,OAAO,CAAC,EAAE,SAAS,CAAA;IACnB,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,oBAAoB,CAAC,EAAE,mBAAmB,CAAA;CAC3C;AAKD,wBAAgB,wBAAwB,CACtC,OAAO,GAAE,yBAA8B,GACtC,gBAAgB,CAgGlB"}
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Destination policy for this plugin's fetch provider.
3
+ *
4
+ * DSH's built-in provider resolves every destination and validates and pins the
5
+ * address before it connects. That cannot work on a machine whose proxy answers
6
+ * DNS with its own fake-ip range (`198.18.0.0/15` for Clash/Mihomo, the usual
7
+ * companion of a system proxy): the hostname is ordinary, the answer is reserved
8
+ * space, and every request is refused before the proxy that could have resolved
9
+ * it is ever consulted. This provider exists for that machine, so it cannot
10
+ * validate names the way the built-in one does.
11
+ *
12
+ * It keeps the half of the policy that needs no resolution — an address the URL
13
+ * states outright is refused unless it is globally reachable unicast, because a
14
+ * proxy on this machine must not become a path into loopback or a LAN — and it
15
+ * also refuses a name this machine resolves into private space. Only the proxy's
16
+ * own fake-ip answers are accepted, since that is what the proxy's name
17
+ * resolution looks like from here; the URL hostname still reaches the proxy
18
+ * intact, so the proxy decides where the name really goes.
19
+ *
20
+ * @module dsh-chatgpt-subscription/fetch-address-policy
21
+ */
22
+ /**
23
+ * Resolve one hostname to every address the local resolver returns, or to no
24
+ * addresses when it cannot.
25
+ *
26
+ * A failure is not evidence of a local destination: the configured proxy
27
+ * resolves the origin itself, so a name this machine cannot resolve must not
28
+ * fail a request the proxy could have served.
29
+ *
30
+ * @param hostname - the URL hostname, without brackets.
31
+ * @returns the resolved addresses, or an empty list when resolution failed.
32
+ */
33
+ export declare function lookupHostAddresses(hostname: string): Promise<readonly string[]>;
34
+ /**
35
+ * Whether a host is stated as an IP address rather than as a name.
36
+ *
37
+ * @param hostname - a URL hostname, bracketed or not.
38
+ * @returns true when the host is an IPv4 or IPv6 literal.
39
+ */
40
+ export declare function isIpLiteral(hostname: string): boolean;
41
+ /**
42
+ * Whether an address is one only a proxy's fake-ip resolver hands out. The IANA
43
+ * benchmarking range carries no real host, so an answer inside it is this
44
+ * machine's proxy claiming the name, not a private destination.
45
+ *
46
+ * @param address - a textual IPv4 (or IPv4-mapped IPv6) address.
47
+ * @returns true when the address is inside the proxy fake-ip range.
48
+ */
49
+ export declare function isProxyFakeIpAddress(address: string): boolean;
50
+ /**
51
+ * Whether an address is globally reachable unicast. IPv4-mapped IPv6 is
52
+ * classified by the IPv4 address it embeds; every other IPv6 address is public
53
+ * only inside `2000::/3`, which excludes loopback, unique-local, link-local,
54
+ * multicast, and the transition and translation prefixes whose real destination
55
+ * cannot be seen from the address alone.
56
+ *
57
+ * @param address - a textual IPv4 or IPv6 address, bracketed or not.
58
+ * @returns true only for a public unicast destination.
59
+ */
60
+ export declare function isPublicIpAddress(address: string): boolean;
61
+ /**
62
+ * Whether a hostname is an IP literal no request may target.
63
+ *
64
+ * @param hostname - a URL hostname, bracketed or not.
65
+ * @returns true when the host is a literal address that is not public.
66
+ */
67
+ export declare function isNonPublicIpLiteral(hostname: string): boolean;
68
+ /**
69
+ * Refuse a fetch destination the local network policy exists to keep out of
70
+ * reach, before any request is made.
71
+ *
72
+ * @param hostname - the URL hostname, bracketed or not.
73
+ * @param addresses - every address the local resolver returned for the host.
74
+ * @throws WebError `WEB_BLOCKED_URL` for a non-public literal, or for a name
75
+ * this machine resolves into private space that is not the proxy's fake-ip.
76
+ */
77
+ export declare function assertPublicFetchTarget(hostname: string, addresses: readonly string[]): void;
78
+ //# sourceMappingURL=fetch-address-policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch-address-policy.d.ts","sourceRoot":"","sources":["../../../src/host/fetch-address-policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAkCH;;;;;;;;;;GAUG;AACH,wBAAsB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,MAAM,EAAE,CAAC,CAOtF;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAErD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAG7D;AAED;;;;;;;;;GASG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAQ1D;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAG9D;AAED;;;;;;;;GAQG;AACH,wBAAgB,uBAAuB,CAAC,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,MAAM,EAAE,GAAG,IAAI,CAQ5F"}
@@ -3,13 +3,27 @@ import type { SearchProviderPreference } from '../shared/contracts.ts';
3
3
  interface LoaderLike {
4
4
  entries(): Iterable<Entry>;
5
5
  }
6
+ /** What one selection asks for on top of the settings preference. */
7
+ export interface WebProviderSelectionOptions {
8
+ /**
9
+ * Whether this plugin's fetch provider must serve the `web_fetch` tool.
10
+ *
11
+ * DSH's built-in provider resolves and pins every destination before it
12
+ * connects and routes through a proxy only when the process environment names
13
+ * one, so an OS-level proxy plus the fake-ip DNS that usually comes with it
14
+ * fails every fetch with `WEB_BLOCKED_URL`. Selecting this plugin's provider
15
+ * hands the origin's resolution to the configured proxy — the same
16
+ * proxied-hop semantics DSH applies to a URL it routes through a proxy.
17
+ */
18
+ readonly pluginFetch?: boolean;
19
+ }
6
20
  export declare class SearchProviderSwitcher {
7
21
  private readonly loader;
8
22
  private originalSearchProvider;
9
23
  private originalFetchProvider;
10
24
  private initialized;
11
25
  constructor(loader: LoaderLike);
12
- select(preference: SearchProviderPreference): Promise<void>;
26
+ select(preference: SearchProviderPreference, options?: WebProviderSelectionOptions): Promise<void>;
13
27
  private findWebEntry;
14
28
  }
15
29
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"search-provider-switcher.d.ts","sourceRoot":"","sources":["../../../src/host/search-provider-switcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,mCAAmC,CAAA;AAG9D,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAA;AAEtE,UAAU,UAAU;IAClB,OAAO,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAA;CAC3B;AAED,qBAAa,sBAAsB;IAKrB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAJnC,OAAO,CAAC,sBAAsB,CAAoB;IAClD,OAAO,CAAC,qBAAqB,CAAoB;IACjD,OAAO,CAAC,WAAW,CAAQ;gBAEE,MAAM,EAAE,UAAU;IAEzC,MAAM,CAAC,UAAU,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAkCjE,OAAO,CAAC,YAAY;CAOrB"}
1
+ {"version":3,"file":"search-provider-switcher.d.ts","sourceRoot":"","sources":["../../../src/host/search-provider-switcher.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,mCAAmC,CAAA;AAG9D,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,wBAAwB,CAAA;AAEtE,UAAU,UAAU;IAClB,OAAO,IAAI,QAAQ,CAAC,KAAK,CAAC,CAAA;CAC3B;AAED,qEAAqE;AACrE,MAAM,WAAW,2BAA2B;IAC1C;;;;;;;;;OASG;IACH,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAA;CAC/B;AAED,qBAAa,sBAAsB;IAKrB,OAAO,CAAC,QAAQ,CAAC,MAAM;IAJnC,OAAO,CAAC,sBAAsB,CAAoB;IAClD,OAAO,CAAC,qBAAqB,CAAoB;IACjD,OAAO,CAAC,WAAW,CAAQ;gBAEE,MAAM,EAAE,UAAU;IAEzC,MAAM,CAAC,UAAU,EAAE,wBAAwB,EAAE,OAAO,GAAE,2BAAgC,GAAG,OAAO,CAAC,IAAI,CAAC;IAkC5G,OAAO,CAAC,YAAY;CAOrB"}
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,CAAC,MAAM,0BAA0B,CAAA;AAqCxC,yDAAyD;AACzD,MAAM,WAAW,MAAM;IACrB;;;;OAIG;IACH,0BAA0B,CAAC,EAAE,OAAO,CAAA;IACpC,qFAAqF;IACrF,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC7B;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,SAAS,GAAG,YAAY,CAAA;CAC9C;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAI3B,CAAA;AAEF,eAAO,MAAM,MAAM,UAAqE,CAAA;AAExF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,YAAY,GAAE,MAAW,GAAG,IAAI,CA0FnE;AAED,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAA;AACzE,OAAO,EACL,kCAAkC,EAClC,qBAAqB,EACrB,mBAAmB,EACnB,2BAA2B,EAC3B,sBAAsB,EACtB,iCAAiC,EACjC,4BAA4B,EAC5B,kBAAkB,EAClB,cAAc,EACd,gCAAgC,EAChC,uBAAuB,EACvB,0BAA0B,EAC1B,2BAA2B,GAC5B,MAAM,wCAAwC,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,EAAE,yBAAyB,EAAE,MAAM,wBAAwB,CAAA;AAClE,OAAO,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAA;AAChE,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAA;AAClF,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AACtF,OAAO,EAAE,wBAAwB,EAAE,MAAM,gCAAgC,CAAA;AACzE,OAAO,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAA;AACnE,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAA;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAA;AACtE,YAAY,EAAE,UAAU,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AAE/E,OAAO,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAA;AAClE,OAAO,EACL,mBAAmB,EACnB,sBAAsB,EACtB,cAAc,EACd,iBAAiB,GAClB,MAAM,mCAAmC,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAA;AAClG,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,CAAC,MAAM,0BAA0B,CAAA;AAsCxC,yDAAyD;AACzD,MAAM,WAAW,MAAM;IACrB;;;;OAIG;IACH,0BAA0B,CAAC,EAAE,OAAO,CAAA;IACpC,qFAAqF;IACrF,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC7B;;;;OAIG;IACH,kBAAkB,CAAC,EAAE,SAAS,GAAG,YAAY,CAAA;CAC9C;AAED,eAAO,MAAM,MAAM,EAAE,CAAC,CAAC,MAAM,CAI3B,CAAA;AAEF,eAAO,MAAM,MAAM,UAAqE,CAAA;AAExF,wBAAgB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,YAAY,GAAE,MAAW,GAAG,IAAI,CAsGnE;AAED,OAAO,EAAE,YAAY,EAAE,iBAAiB,EAAE,MAAM,yBAAyB,CAAA;AACzE,OAAO,EACL,kCAAkC,EAClC,qBAAqB,EACrB,mBAAmB,EACnB,2BAA2B,EAC3B,sBAAsB,EACtB,iCAAiC,EACjC,4BAA4B,EAC5B,kBAAkB,EAClB,cAAc,EACd,gCAAgC,EAChC,uBAAuB,EACvB,0BAA0B,EAC1B,2BAA2B,GAC5B,MAAM,wCAAwC,CAAA;AAC/C,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAA;AACtD,OAAO,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAA;AACvD,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,EAAE,yBAAyB,EAAE,MAAM,wBAAwB,CAAA;AAClE,OAAO,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAA;AAChE,OAAO,EAAE,eAAe,EAAE,oBAAoB,EAAE,MAAM,4BAA4B,CAAA;AAClF,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,yBAAyB,CAAA;AACtF,OAAO,EAAE,wBAAwB,EAAE,MAAM,gCAAgC,CAAA;AACzE,OAAO,EAAE,qBAAqB,EAAE,MAAM,6BAA6B,CAAA;AACnE,OAAO,EAAE,mBAAmB,EAAE,MAAM,6BAA6B,CAAA;AACjE,OAAO,EAAE,sBAAsB,EAAE,MAAM,+BAA+B,CAAA;AACtE,YAAY,EAAE,UAAU,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAA;AAE/E,OAAO,EAAE,kBAAkB,EAAE,MAAM,+BAA+B,CAAA;AAClE,OAAO,EACL,mBAAmB,EACnB,sBAAsB,EACtB,cAAc,EACd,iBAAiB,GAClB,MAAM,mCAAmC,CAAA;AAC1C,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,uBAAuB,EAAE,MAAM,6BAA6B,CAAA;AAClG,OAAO,EAAE,gBAAgB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eddyskywalker/dsh-chatgpt-subscription",
3
- "version": "0.2.16",
3
+ "version": "0.2.17",
4
4
  "description": "DSH provider plugin for Codex access through a ChatGPT subscription.",
5
5
  "author": {
6
6
  "name": "eddyskywalker",