dsh-web-fetch-enhanced 0.0.2 → 0.0.4

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/README.en.md CHANGED
@@ -29,7 +29,13 @@ With an empty CIDR allowlist, the security boundary remains equivalent to the na
29
29
  - **Redirect revalidation** — same-origin redirects are resolved, validated, and pinned again at every hop;
30
30
  - **Anonymous bounded GET requests** — no cookies, Authorization header, or URL credentials;
31
31
  - **Resource limits** — URL length, response bytes, decoded characters, redirects, and time are bounded;
32
- - **IPv4 and IPv6 coverage** — including IPv4-mapped IPv6 and active DNS64 / NAT64 checks.
32
+ - **IPv4 and IPv6 coverage** — including IPv4-mapped IPv6 and active DNS64 / NAT64 checks;
33
+ - **Outbound HTTP proxy alignment** — seamlessly respects DSH global proxy routes (`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`), while refusing unvalidated private IP literals from bypassing address checks through the proxy.
34
+
35
+ ## Compatibility & Support Policy
36
+
37
+ - **Minimum Supported DSH Version**: `0.1.2-rc.1`
38
+ - **Support Policy**: This plugin **only supports DeepSeek Harness RC (Release Candidate) releases and future stable releases**. Compatibility is not maintained for rapid-moving Alpha or development snapshot versions.
33
39
 
34
40
  ## Quick start
35
41
 
@@ -178,7 +184,7 @@ Most users only need the two allowlists exposed in DSH Web. Manage other values
178
184
  | `maxRedirects` | `5` | Maximum same-origin redirect hops; `0` disables following |
179
185
  | `userAgent` | `dsh-web-fetch-enhanced/0.1.0` | User-Agent sent with each request |
180
186
 
181
- The default bundle uses the separate `http-enhanced` provider ID and leaves the native `http` provider installed. For manual composition or drop-in replacement, see:
187
+ The default bundle uses the separate `http-enhanced` provider ID and explicitly disables the native `web-fetch-http` provider to avoid `WEB_PROVIDER_AMBIGUOUS` when subsequent Web plugins (such as search plugins) overwrite `id: web` configuration and unpin `fetchProvider`. For manual composition or drop-in replacement, see:
182
188
 
183
189
  - [coexisting provider example](examples/coexist.cordis.yml)
184
190
  - [drop-in replacement example](examples/drop-in.cordis.yml)
package/README.md CHANGED
@@ -29,7 +29,13 @@ DeepSeek Harness 原生 HTTP provider 默认拒绝所有非公网地址,这是
29
29
  - **重定向逐跳复核**:仅跟随同源重定向,并在每一跳重新解析、校验和固定地址;
30
30
  - **受限匿名请求**:只发送无 Cookie、无 Authorization、无 URL 凭据的 GET 请求;
31
31
  - **完整资源上限**:限制 URL、响应字节、解码字符、重定向次数和超时时间;
32
- - **IPv4 / IPv6 防护**:覆盖 IPv4-mapped IPv6 与活动 DNS64 / NAT64 目标检查。
32
+ - **IPv4 / IPv6 防护**:覆盖 IPv4-mapped IPv6 与活动 DNS64 / NAT64 目标检查;
33
+ - **全局出站代理协同**:无缝对接 DSH 全局 HTTP 代理路由(`HTTP_PROXY` / `HTTPS_PROXY` / `ALL_PROXY` / `NO_PROXY`),同时严格禁止非公网 IP 字面量越权走代理,杜绝内网 SSRF 风险。
34
+
35
+ ## 版本兼容性与支持策略
36
+
37
+ - **最低支持的 DSH 版本**:`0.1.2-rc.1`
38
+ - **版本支持策略**:本插件**仅对 DeepSeek Harness 的 RC(Release Candidate)候选发布版本及后续稳定正式版提供支持**。由于 Alpha 或开发快照版本更迭频繁且缺乏稳定的 API 保证,本插件不再对 Alpha 等非 RC 阶段版本进行维护与适配。
33
39
 
34
40
  ## 快速开始
35
41
 
@@ -178,7 +184,7 @@ wiki.corp.example
178
184
  | `maxRedirects` | `5` | 同源重定向最大跳数;`0` 表示不跟随 |
179
185
  | `userAgent` | `dsh-web-fetch-enhanced/0.1.0` | 每个请求使用的 User-Agent |
180
186
 
181
- 默认安装采用独立的 `http-enhanced` provider ID,并保留原生 `http` provider。需要手动组合或 drop-in 替换时,参考:
187
+ 默认安装(通过 bundle `cordis.patch.yml`)采用独立的 `http-enhanced` provider ID,并显式禁用原生的 `web-fetch-http`,以避免后续其他 Web 插件(如 search 插件)覆盖 `id: web` 的 config 时因 `fetchProvider` 变成未指定而触发 `WEB_PROVIDER_AMBIGUOUS`。需要自定义组合或 drop-in 替换时,参考:
182
188
 
183
189
  - [独立 provider 示例](examples/coexist.cordis.yml)
184
190
  - [drop-in 替换示例](examples/drop-in.cordis.yml)
package/cordis.patch.yml CHANGED
@@ -1,9 +1,10 @@
1
1
  # Optional Profile Bundle: select the enhanced HTTP fetch provider on the Host plane.
2
- # This layer is applied after @deepseek-ai/dsh-base. A patch replaces a row's
3
- # entire config, so both WebRuntime selector fields are restated here.
2
+ # Disable the native HTTP fetch provider to avoid WEB_PROVIDER_AMBIGUOUS when fetchProvider is unpinned by later bundles.
3
+ - id: web-fetch-http
4
+ disabled: true
5
+
4
6
  - id: web
5
7
  config:
6
- searchProvider: deepseek-official
7
8
  fetchProvider: http-enhanced
8
9
 
9
10
  - insert:
@@ -47,13 +47,13 @@ flowchart LR
47
47
  <code>ctx.web</code> 的选择是 ID 驱动而不是注册顺序驱动:
48
48
 
49
49
  - 配置 <code>fetchProvider: http-enhanced</code> 时只选择本插件默认 ID;
50
- - 保留原生 <code>http</code> provider 不会产生歧义,因为选择已显式固定;
50
+ - 默认 bundle 会在 Profile 中显式禁用原生的 <code>web-fetch-http</code>,从而在多 Web 插件(如 search 插件)覆盖 <code>id: web</code> config 导致 <code>fetchProvider</code> 未固定时,系统只存在唯一的可用 fetch provider,避免 <code>WEB_PROVIDER_AMBIGUOUS</code>;
51
51
  - 未配置 ID 且存在多个可用 provider 时会报 <code>WEB_PROVIDER_AMBIGUOUS</code>;
52
52
  - 两个 provider 注册相同 ID 会立即报 <code>WEB_DUPLICATE_PROVIDER</code>。
53
53
 
54
54
  所以“覆盖原生”有两种明确模式:
55
55
 
56
- 1. 推荐模式:本插件使用 <code>http-enhanced</code>,修改现有 web 行的 <code>fetchProvider</code>;
56
+ 1. 推荐模式:本插件使用 <code>http-enhanced</code>,修改现有 web 行的 <code>fetchProvider</code> 并禁用原生 <code>web-fetch-http</code>(由随包附带的 <code>cordis.patch.yml</code> 自动处理);
57
57
  2. drop-in 模式:禁用原生 provider,本插件配置 <code>providerId: http</code>。
58
58
 
59
59
  不存在 last-wins 或自动 fallback。
@@ -1,8 +1,6 @@
1
1
  # Profile user patch after installing the bundle, retaining fetchProvider: http.
2
- # A patch replaces a row's whole config, so both WebRuntime fields are restated.
3
2
  - id: web
4
3
  config:
5
- searchProvider: deepseek-official
6
4
  fetchProvider: http
7
5
 
8
6
  # The native provider MUST be disabled to avoid duplicate provider id "http".
@@ -1,8 +1,10 @@
1
1
  # Manual Host overlay when the package is resolvable but its dsh.bundle layer
2
2
  # is not active. Bundle users should use coexist.cordis.yml instead.
3
+ - id: web-fetch-http
4
+ disabled: true
5
+
3
6
  - id: web
4
7
  config:
5
- searchProvider: deepseek-official
6
8
  fetchProvider: http-enhanced
7
9
 
8
10
  - insert:
package/lib/index.js CHANGED
@@ -39,6 +39,22 @@ var AddressPolicy = class {
39
39
  });
40
40
  }
41
41
  };
42
+ /** Return true only for globally reachable unicast, matching the native provider. */
43
+ function isPublicIpAddress(input) {
44
+ const parsed = parseAddress(input);
45
+ return parsed !== void 0 && isPublicAddress(parsed);
46
+ }
47
+ /**
48
+ * Whether a hostname is an IP literal that resolves to a non-public address.
49
+ *
50
+ * A proxied hop skips address resolution because the proxy resolves the origin,
51
+ * but an IP literal needs no resolution. Handing an unverified non-public literal
52
+ * to a proxy would risk reaching loopback or private services (SSRF).
53
+ */
54
+ function isNonPublicIpLiteral(hostname) {
55
+ const unbracketed = stripIpv6Brackets(hostname);
56
+ return isIP(unbracketed) !== 0 && !isPublicIpAddress(unbracketed);
57
+ }
42
58
  function isPublicAddress(address) {
43
59
  return address.range() === "unicast";
44
60
  }
@@ -206,6 +222,22 @@ function embeddedIpv4Address(bytes, prefixLength) {
206
222
  const beforeReservedOctet = 8 - prefixBytes;
207
223
  return [...bytes.slice(prefixBytes, prefixBytes + beforeReservedOctet), ...bytes.slice(9, 13 - beforeReservedOctet)].join(".");
208
224
  }
225
+ /**
226
+ * Default proxy route resolver: safely resolves proxy route via @deepseek-ai/dsh-http-proxy if available,
227
+ * gracefully degrading to direct routing if the module is absent.
228
+ */
229
+ async function defaultProxyRoute(url) {
230
+ try {
231
+ const { proxyRouteFor } = await import("@deepseek-ai/dsh-http-proxy");
232
+ const route = proxyRouteFor(url);
233
+ return route.proxied ? {
234
+ proxied: true,
235
+ dispatcher: route.dispatcher
236
+ } : { proxied: false };
237
+ } catch {
238
+ return { proxied: false };
239
+ }
240
+ }
209
241
  /** Fetch while preserving the URL hostname for Host and TLS SNI. */
210
242
  async function requestPinned(url, addresses, headers, signal) {
211
243
  const { Agent, fetch } = await import("undici");
@@ -231,6 +263,24 @@ async function requestPinned(url, addresses, headers, signal) {
231
263
  throw error;
232
264
  }
233
265
  }
266
+ /**
267
+ * Fetch through the dispatcher the proxy policy already installed, letting the proxy resolve the origin.
268
+ * No address set is pinned because the proxy performs the lookup. The dispatcher is process-wide,
269
+ * so connections are pooled and the caller does not close it.
270
+ */
271
+ async function requestVia(dispatcher, url, headers, signal) {
272
+ const { fetch } = await import("undici");
273
+ return {
274
+ response: await fetch(url, {
275
+ method: "GET",
276
+ redirect: "manual",
277
+ headers,
278
+ signal,
279
+ dispatcher
280
+ }),
281
+ close: () => Promise.resolve()
282
+ };
283
+ }
234
284
  /** Create a Node lookup callback that returns only the prevalidated answer set. */
235
285
  function createPinnedLookup(addresses) {
236
286
  return (hostname, options, callback) => {
@@ -374,10 +424,12 @@ var EnhancedHttpFetchProvider = class {
374
424
  id;
375
425
  limits;
376
426
  resolveAddresses;
377
- constructor(id, limits, resolveAddresses) {
427
+ resolveProxy;
428
+ constructor(id, limits, resolveAddresses, resolveProxy = defaultProxyRoute) {
378
429
  this.id = id;
379
430
  this.limits = limits;
380
431
  this.resolveAddresses = resolveAddresses;
432
+ this.resolveProxy = resolveProxy;
381
433
  }
382
434
  available() {
383
435
  return true;
@@ -436,11 +488,14 @@ var EnhancedHttpFetchProvider = class {
436
488
  }
437
489
  }
438
490
  async requestOnce(url, signal) {
491
+ const headers = {
492
+ "user-agent": this.limits.userAgent,
493
+ "accept": "text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8"
494
+ };
439
495
  try {
440
- return await requestPinned(url, await this.resolveAddresses(url.hostname, signal), {
441
- "user-agent": this.limits.userAgent,
442
- "accept": "text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8"
443
- }, signal);
496
+ const route = await this.resolveProxy(url);
497
+ if (route.proxied && route.dispatcher && !isNonPublicIpLiteral(url.hostname)) return await requestVia(route.dispatcher, url, headers, signal);
498
+ return await requestPinned(url, await this.resolveAddresses(url.hostname, signal), headers, signal);
444
499
  } catch (error) {
445
500
  if (error instanceof WebError) throw error;
446
501
  throw translateAbortOrNetwork(error, signal);
@@ -574,7 +629,7 @@ const Config = z.object({
574
629
  userAgent: z.string().default(DEFAULT_USER_AGENT)
575
630
  });
576
631
  /** Construct the provider without mounting it, useful for tests and custom compositions. */
577
- function createProvider(config = {}) {
632
+ function createProvider(config = {}, proxyResolver) {
578
633
  const resolved = resolveConfig(config);
579
634
  assertProviderId(resolved.providerId);
580
635
  assertPositiveFinite("maxResponseBytes", resolved.maxResponseBytes);
@@ -592,7 +647,7 @@ function createProvider(config = {}) {
592
647
  maxRedirects: resolved.maxRedirects,
593
648
  userAgent: resolved.userAgent
594
649
  };
595
- return new EnhancedHttpFetchProvider(resolved.providerId, limits, createAllowlistResolver(policy));
650
+ return new EnhancedHttpFetchProvider(resolved.providerId, limits, createAllowlistResolver(policy), proxyResolver);
596
651
  }
597
652
  function isUnloading(ctx) {
598
653
  const state = ctx.fiber?.state;
@@ -665,4 +720,4 @@ function assertNonNegativeInteger(field, value) {
665
720
  if (!Number.isInteger(value) || value < 0) throw new Error(`web-fetch-enhanced: ${field} must be a non-negative integer`);
666
721
  }
667
722
  //#endregion
668
- export { Config, DEFAULT_PROVIDER_ID, DEFAULT_USER_AGENT, SETTINGS_NAMESPACE, apply, createProvider, inject, name };
723
+ export { Config, DEFAULT_PROVIDER_ID, DEFAULT_USER_AGENT, SETTINGS_NAMESPACE, apply, createProvider, defaultProxyRoute, inject, isNonPublicIpLiteral, name };
@@ -19,6 +19,14 @@ export declare class AddressPolicy {
19
19
  }
20
20
  /** Return true only for globally reachable unicast, matching the native provider. */
21
21
  export declare function isPublicIpAddress(input: string): boolean;
22
+ /**
23
+ * Whether a hostname is an IP literal that resolves to a non-public address.
24
+ *
25
+ * A proxied hop skips address resolution because the proxy resolves the origin,
26
+ * but an IP literal needs no resolution. Handing an unverified non-public literal
27
+ * to a proxy would risk reaching loopback or private services (SSRF).
28
+ */
29
+ export declare function isNonPublicIpLiteral(hostname: string): boolean;
22
30
  /** WHATWG URL retains brackets around IPv6 hostnames; IP parsers do not. */
23
31
  export declare function stripIpv6Brackets(hostname: string): string;
24
32
  //# sourceMappingURL=address-policy.d.ts.map
@@ -8,6 +8,7 @@ import type { Context } from '@deepseek-ai/cordis';
8
8
  import z from '@deepseek-ai/schemastery';
9
9
  import type { SettingsNamespace } from '@deepseek-ai/dsh-settings';
10
10
  import { EnhancedHttpFetchProvider } from './provider.ts';
11
+ import type { ProxyRouteResolver } from './resolver.ts';
11
12
  /** Explicit product User-Agent used by default. */
12
13
  export declare const DEFAULT_USER_AGENT = "dsh-web-fetch-enhanced/0.1.0";
13
14
  /** Default provider id; select it in the dsh-web row with fetchProvider. */
@@ -39,7 +40,10 @@ export interface Config {
39
40
  }
40
41
  export declare const Config: z<Config>;
41
42
  /** Construct the provider without mounting it, useful for tests and custom compositions. */
42
- export declare function createProvider(config?: Config): EnhancedHttpFetchProvider;
43
+ export declare function createProvider(config?: Config, proxyResolver?: ProxyRouteResolver): EnhancedHttpFetchProvider;
44
+ export { isNonPublicIpLiteral } from './address-policy.ts';
45
+ export type { ProxyRouteResolver, ProxyRouteResult } from './resolver.ts';
46
+ export { defaultProxyRoute } from './resolver.ts';
43
47
  /** Register the enhanced fetch provider and its live Web Profile settings section. */
44
48
  export declare function apply(ctx: Context, config: Config): void;
45
49
  //# sourceMappingURL=index.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import type { WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web';
2
- import type { FetchResolver } from './resolver.ts';
2
+ import type { FetchResolver, ProxyRouteResolver } from './resolver.ts';
3
3
  /** Resolved transport and response limits. */
4
4
  export interface HttpFetchLimits {
5
5
  maxResponseBytes: number;
@@ -13,7 +13,8 @@ export declare class EnhancedHttpFetchProvider implements WebFetchProvider {
13
13
  readonly id: string;
14
14
  private readonly limits;
15
15
  private readonly resolveAddresses;
16
- constructor(id: string, limits: HttpFetchLimits, resolveAddresses: FetchResolver);
16
+ private readonly resolveProxy;
17
+ constructor(id: string, limits: HttpFetchLimits, resolveAddresses: FetchResolver, resolveProxy?: ProxyRouteResolver);
17
18
  available(): boolean;
18
19
  fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;
19
20
  private followAndRead;
@@ -1,5 +1,5 @@
1
1
  import type { LookupAddress, LookupOptions } from 'node:dns';
2
- import type { Response } from 'undici';
2
+ import type { Dispatcher, Response } from 'undici';
3
3
  import { AddressPolicy } from './address-policy.ts';
4
4
  /** DNS resolver shape used by Node and by focused tests. */
5
5
  export type AddressResolver = (hostname: string, options: {
@@ -22,8 +22,29 @@ export interface PinnedResponse {
22
22
  export declare function createAllowlistResolver(policy: AddressPolicy, resolver?: AddressResolver): FetchResolver;
23
23
  /** Resolve once and reject the complete answer set when any entry is disallowed. */
24
24
  export declare function resolveAllowedAddresses(hostname: string, signal: AbortSignal, policy: AddressPolicy, resolver?: AddressResolver): Promise<ValidatedAddress[]>;
25
+ /** Result of resolving a URL through the outbound proxy policy. */
26
+ export type ProxyRouteResult = {
27
+ readonly proxied: true;
28
+ readonly dispatcher: Dispatcher;
29
+ } | {
30
+ readonly proxied: false;
31
+ readonly dispatcher?: undefined;
32
+ };
33
+ /** Function deciding whether a URL should be routed through an outbound proxy. */
34
+ export type ProxyRouteResolver = (url: URL) => Promise<ProxyRouteResult> | ProxyRouteResult;
35
+ /**
36
+ * Default proxy route resolver: safely resolves proxy route via @deepseek-ai/dsh-http-proxy if available,
37
+ * gracefully degrading to direct routing if the module is absent.
38
+ */
39
+ export declare function defaultProxyRoute(url: URL): Promise<ProxyRouteResult>;
25
40
  /** Fetch while preserving the URL hostname for Host and TLS SNI. */
26
41
  export declare function requestPinned(url: URL, addresses: readonly ValidatedAddress[], headers: Record<string, string>, signal: AbortSignal): Promise<PinnedResponse>;
42
+ /**
43
+ * Fetch through the dispatcher the proxy policy already installed, letting the proxy resolve the origin.
44
+ * No address set is pinned because the proxy performs the lookup. The dispatcher is process-wide,
45
+ * so connections are pooled and the caller does not close it.
46
+ */
47
+ export declare function requestVia(dispatcher: Dispatcher, url: URL, headers: Record<string, string>, signal: AbortSignal): Promise<PinnedResponse>;
27
48
  type LookupCallback = (error: NodeJS.ErrnoException | null, address: string | LookupAddress[], family?: number) => void;
28
49
  /** Create a Node lookup callback that returns only the prevalidated answer set. */
29
50
  export declare function createPinnedLookup(addresses: readonly ValidatedAddress[]): (hostname: string, options: LookupOptions, callback: LookupCallback) => void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-web-fetch-enhanced",
3
- "version": "0.0.2",
3
+ "version": "0.0.4",
4
4
  "description": "Configurable non-public address allowlists for DeepSeek Harness web_fetch",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -69,15 +69,21 @@
69
69
  },
70
70
  "peerDependencies": {
71
71
  "@deepseek-ai/cordis": "^4.0.1 || ^4.0.2",
72
- "@deepseek-ai/dsh-timeout": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.2-alpha.2",
73
- "@deepseek-ai/dsh-settings": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.2-alpha.2",
74
- "@deepseek-ai/dsh-client-runtime": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.2-alpha.2",
75
- "@deepseek-ai/dsh-client-locale": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.2-alpha.2",
76
- "@deepseek-ai/dsh-client-ui-renderer": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.2-alpha.2",
77
- "@deepseek-ai/dsh-client-ui-settings": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.2-alpha.2",
78
- "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.2-alpha.2",
79
- "@deepseek-ai/dsh-client-ui-slots": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.2-alpha.2",
80
- "@deepseek-ai/dsh-web": "^0.1.1-rc.2 || ^0.1.2-alpha.1 || ^0.1.2-alpha.2"
72
+ "@deepseek-ai/dsh-timeout": "^0.1.2-rc.1",
73
+ "@deepseek-ai/dsh-settings": "^0.1.2-rc.1",
74
+ "@deepseek-ai/dsh-client-runtime": "^0.1.2-rc.1",
75
+ "@deepseek-ai/dsh-client-locale": "^0.1.2-rc.1",
76
+ "@deepseek-ai/dsh-client-ui-renderer": "^0.1.2-rc.1",
77
+ "@deepseek-ai/dsh-client-ui-settings": "^0.1.2-rc.1",
78
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "^0.1.2-rc.1",
79
+ "@deepseek-ai/dsh-client-ui-slots": "^0.1.2-rc.1",
80
+ "@deepseek-ai/dsh-web": "^0.1.2-rc.1",
81
+ "@deepseek-ai/dsh-http-proxy": "^0.1.2-rc.1"
82
+ },
83
+ "peerDependenciesMeta": {
84
+ "@deepseek-ai/dsh-http-proxy": {
85
+ "optional": true
86
+ }
81
87
  },
82
88
  "dependencies": {
83
89
  "@deepseek-ai/schemastery": "^3.18.1 || ^3.18.2",