@qilin-agent/http-proxy 0.0.0-stage → 3.1.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kkutysllb
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,55 @@
1
+ # Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
2
+ # section, a hash of its English and Chinese blocks outside code blocks and generated regions.
3
+ # After editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/util/http-proxy/README.md
5
+ /:
6
+ en: 117b4d191f773a9c
7
+ zh: 0b9e150f6cd23b04
8
+ /qilin-http-proxy:
9
+ en: e3d33d2aa4e5270e
10
+ zh: 3502335e912012e5
11
+ /qilin-http-proxy/summary:
12
+ en: c3b1b32ab36f5f97
13
+ zh: 143b744ab361f0d4
14
+ /qilin-http-proxy/table-of-contents:
15
+ en: d152484eb41ac6b4
16
+ zh: 09388d293f9be9cb
17
+ /qilin-http-proxy/use-this-package:
18
+ en: 559207e93d89f1a9
19
+ zh: 8f6975b4c42589b4
20
+ /qilin-http-proxy/use-this-package/writing-a-new-outbound-call:
21
+ en: 782b24f91ee8ce71
22
+ zh: af4b75a0d5a98fdc
23
+ /qilin-http-proxy/use-this-package/what-the-policy-reads:
24
+ en: 5ebd23809d3f0ded
25
+ zh: 4913ca6a8935e2ac
26
+ /qilin-http-proxy/use-this-package/failures:
27
+ en: 4248bc9a662680ed
28
+ zh: 4150ade9f1aa9287
29
+ /qilin-http-proxy/understand-the-implementation:
30
+ en: ee4615a04fa6503d
31
+ zh: ee2c5fb2204a43cf
32
+ /qilin-http-proxy/understand-the-implementation/design-philosophy:
33
+ en: 4c5ed05a5cd07bc3
34
+ zh: a6aee9a484e92a79
35
+ /qilin-http-proxy/understand-the-implementation/source-map:
36
+ en: a099f88dd0c7d394
37
+ zh: 7325b7ea973bce13
38
+ /qilin-http-proxy/understand-the-implementation/bypass-matching:
39
+ en: e9ad6c2e8ee2fc21
40
+ zh: c10de6995cf594bc
41
+ /qilin-http-proxy/further-exploration:
42
+ en: 3712e481f8cbeb24
43
+ zh: 390dd890d3d66e70
44
+ /qilin-http-proxy/model-experience:
45
+ en: e0af6a1073934d5a
46
+ zh: d8bf9c77b54ed5ae
47
+ /qilin-http-proxy/model-experience/kv-cache-effect:
48
+ en: 7f12ca92e7722f98
49
+ zh: 97fa75de6a3f6a3f
50
+ /qilin-http-proxy/known-limitations-and-deferred-work:
51
+ en: 4367d85a8e5e0101
52
+ zh: 68a5c63e23aed832
53
+ /qilin-http-proxy/known-limitations-and-deferred-work/dev-note:
54
+ en: 8f915ac6f4fc1525
55
+ zh: 29b06e0aae5a30ec
package/README.md CHANGED
@@ -1,3 +1,122 @@
1
- # Temporary Holding Version
1
+ ---
2
+ description: "Outbound HTTP proxy support for the harness: how one policy resolved from the launch environment reaches every request Node's fetch would otherwise send direct."
3
+ kind: "package-reference"
4
+ ---
2
5
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
6
+ # @qilin-agent/http-proxy
7
+
8
+ English | [中文](README.zh.md)
9
+
10
+ ## Summary
11
+
12
+ Use this package to apply one outbound HTTP proxy policy to Harness requests that use Node's built-in `fetch`, including LLM, web-search, and HTTP MCP traffic. The launcher reads standard proxy environment variables once, and ordinary `fetch` callers require no extra imports or changes. Local loopback traffic stays direct, while unsupported proxy URLs are reported and skipped for the affected scheme. Public helpers let callers route transports with their own proxy settings, prepare child-process environments, or clear proxy variables for isolated replays.
13
+
14
+ ## Table of Contents
15
+
16
+ - [Use this package](#use-this-package)
17
+ - [Understand the implementation](#understand-the-implementation)
18
+ - [Further Exploration](#further-exploration)
19
+ - [Model Experience](#model-experience)
20
+ - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
21
+ - [Dev Note](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## Use this package
27
+
28
+ Nothing to mount, and nothing to configure. The `qilin` launcher resolves and installs the policy for every profile before the first plugin loads, so a user who exports `HTTPS_PROXY` is proxied everywhere. This is a library rather than a plugin because transport policy has one answer per process: there is no second implementation to swap and no scope narrower than the process to give one.
29
+
30
+ ### Writing a new outbound call
31
+
32
+ Plain `fetch()` is proxied, and so is any SDK that reaches `globalThis.fetch` — the MCP HTTP transport and the pi-ai provider stack both do. Verify each SDK's actual transport; exceptions belong under [Known Limitations and Deferred Work](#known-limitations-and-deferred-work).
33
+
34
+ | You are writing | Use |
35
+ |---|---|
36
+ | A plain request, or an SDK that reaches `globalThis.fetch` | nothing — the global dispatcher already routes it |
37
+ | A call that must branch on whether this request is proxied | `proxyRouteFor(url)` |
38
+ | An SDK that takes a proxy URL of its own | `proxyRouteFor(url)`, and pass `route.proxy` |
39
+ | A spawn whose environment you build yourself | apply `proxyEnvironmentForChild()` to it (`undefined` means remove) |
40
+ | A harness that must reach its own fixture server | apply `clearedProxyEnv()` to the spawn |
41
+
42
+ `proxyRouteFor` answers with the transport that answer assumed, not just the answer: its proxied arm carries the dispatcher already routing by this policy. A caller that read the policy and then built its own transport could have an unmount land between the two and send the request somewhere its branch never cleared.
43
+
44
+ Constructing `new Agent(...)` and passing it as `dispatcher` overrides the global one and silently bypasses the proxy. `verify-no-bare-dispatcher` rejects that outside this package. One call site legitimately owns its transport — `web-fetch-http` pins a request to addresses it validated, which is per-request state a process-wide dispatcher cannot hold — and says so with a `proxy-exempt:` comment on the line.
45
+
46
+ That gate cannot see inside an SDK, so each outbound call site carries an `egress.spec.ts` that drives its actual transport through a fake proxy and checks the observed route. Every new outbound call site must include that transport test. Telemetry asserts its direct-route exception. These tests detect dependency changes that alter routing without changing the call site.
47
+
48
+ ### What the policy reads
49
+
50
+ `http_proxy`, `https_proxy`, `no_proxy`, and `all_proxy`, lowercase first and uppercase as the fallback, with a blank value treated as unset. `ALL_PROXY` backs both schemes, and HTTPS falls back to the HTTP proxy last — neither Node nor undici derives the first of these on its own. Values come from the launcher's snapshot: an exported variable first, then `$QILIN_HOME/.env`. A project's own `.env` cannot carry these names — that file arrives with a clone, and the launcher refuses to start rather than let a repository choose where the harness sends its traffic.
51
+
52
+ Loopback is always bypassed — `localhost`, the whole `127.0.0.0/8` range, `::1`, `0.0.0.0`, and the IPv4-mapped spellings of those. The harness's own Web UI, Connection transport, and every local test server would otherwise route through the proxy and loop. The published bypass list names only the four literal entries an environment reader can match; `proxyForUrl` recognises the range itself, because a list entry cannot express one.
53
+
54
+ ### Failures
55
+
56
+ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable string, an unsupported scheme — is reported and skipped, and that scheme connects directly. The variable may have been exported for other tools, so it must not stop the agent from starting.
57
+
58
+ -----
59
+
60
+ <a id="understand-the-implementation"></a>
61
+ ## Understand the implementation
62
+
63
+ ### Design philosophy
64
+
65
+ **One resolution, one matcher.** `proxyForUrl()` and the installed dispatcher must never disagree about a URL, or `qilin-web-fetch-http` would pin a connection the dispatcher meant to tunnel. The dispatcher is therefore an `Agent` whose per-origin `factory` calls `proxyForUrl()` itself, so there is no second parser to drift from the first. undici's `EnvHttpProxyAgent` cannot serve here: with no `HTTPS_PROXY` present it reuses the HTTP proxy for `https:`, which would tunnel a scheme this package keeps direct after refusing the URL the user named for it.
66
+
67
+ **A child inherits the user's own values, and the resolved policy for what they left unset.** A scheme the user named in either casing reaches a child exactly as they wrote it, so a SOCKS proxy `curl` uses is never replaced by an HTTP one named for another scheme. A scheme they named in neither casing carries the resolved value instead, because otherwise the child's routing diverges from its parent's: Node's `NODE_USE_ENV_PROXY` does not read `ALL_PROXY`. The bypass list is always the resolved one — it only ever adds the loopback entries, so nothing the user wrote is lost. The cost of one routing answer for parent and child alike is that `curl` also sees the `https:` proxy this package derives from the HTTP one. One exception protects the child itself: when a value it receives is one this package refused — a SOCKS URL kept for `curl` — the `NODE_USE_ENV_PROXY` flag is withheld, because Node parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program and exits on such a value. A child Node then connects directly, as this process already reported for that scheme, instead of failing to start.
68
+
69
+ ### Source map
70
+
71
+ | File | Holds |
72
+ |---|---|
73
+ | `src/policy.ts` | Resolution and bypass matching; a diagnostic names the variable, never its value. Imports no transport, so it stays loadable where undici is absent. |
74
+ | `src/install.ts` | The global dispatcher, the active-policy record, the route, and the child environment. Imports undici dynamically. |
75
+ | `src/index.ts` | The package face: four functions and one type. |
76
+
77
+ ### Bypass matching
78
+
79
+ An entry names a host and matches it together with every subdomain under it: `NO_PROXY=example.com` also bypasses `api.example.com`. A leading `.` or `*.` is accepted and means the same thing. An entry may carry a `:port`, and `*` bypasses everything. A bracketed or bare IPv6 literal matches either way — a bare `::1` is *not* read as host `:` port `1`, which is how undici's own matcher fails and why the resolved list carries both `::1` and `[::1]`. CIDR is not matched: an operating system's bypass list often carries `10.0.0.0/8`, which has to be rewritten as suffixes.
80
+
81
+ -----
82
+
83
+ <a id="further-exploration"></a>
84
+ ## Further Exploration
85
+
86
+ - [Network proxy guide](../../../docs/user/guide/network-proxy.md) — what to export, and why a browser is proxied when a terminal is not.
87
+ - [`qilin-web-fetch-http`](../../web/web-fetch-http/README.md) — the one consumer whose safety rules change under a proxy.
88
+
89
+ -----
90
+
91
+ <a id="model-experience"></a>
92
+ ## Model Experience
93
+
94
+ None, as transport policy only: it changes how bytes reach the network and registers no prompt, schema, or result text.
95
+
96
+ #### KV Cache effect
97
+
98
+ No direct invalidation: the package contributes no request tokens and never mutates a request prefix, so provider cache reuse is unaffected.
99
+
100
+ ## Known Limitations and Deferred Work
101
+
102
+ <a id="known-limitations-and-deferred-work"></a>
103
+
104
+
105
+ These limits define when the package is a poor fit. They are current package constraints.
106
+
107
+ - **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment. A macOS or Windows system-proxy setting is not read, so a user who only toggled it in a proxy application must still export the variables; a SOCKS URL is reported and that scheme stays direct rather than borrowing another scheme's proxy.
108
+ - **No custom certificate authority** — a TLS-intercepting corporate proxy needs `NODE_EXTRA_CA_CERTS` set on the process before launch, which this package neither sets nor validates.
109
+ - **A spawned child honors the policy only on a new enough runtime, and only when every value it inherits is one Node accepts** — it reads the published environment through Node's `NODE_USE_ENV_PROXY` (22.21+, 24+), and the engines range admits 22.19 and 22.20, where such a child stays direct. A user whose environment also names a SOCKS or otherwise refused proxy leaves every child Node direct: the flag is withheld so the child can start at all. A child also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in their separators and IPv4-range support. Nothing in this process depends on a Node version: every in-process request reaches the global dispatcher.
110
+ - **Telemetry is direct by design** — the OTLP exporter posts through `node:http`, which no global dispatcher reaches. Routing it would need either an `http.Agent` whose `proxyEnv` option post-dates the lowest supported Node, or the SDK's `fetch` transport, which has no compression while the shipped profile enables gzip. Telemetry is the one channel whose loss costs the user nothing, so it stays where it was; `QILIN_TELEMETRY_MODE=DISABLED` turns it off.
111
+ - **Model-authored programs receive no proxy settings** — the Node ptc-runtime process and workflow worker do not inherit a proxy URL that may contain `user:password`. Their direct requests need their own configuration and remain subject to the execution sandbox.
112
+ - **The regression gate sees source, not dependencies** — `verify-no-bare-dispatcher` parses `packages/*/*/src` and `apps/*/src`; tests, scripts, and the internals of a third-party SDK are outside it. That is why every outbound call site also carries an `egress.spec.ts`.
113
+
114
+ <a id="dev-note"></a>
115
+ ### Dev Note
116
+
117
+ <details>
118
+ <summary>Working context for maintainers — click to expand</summary>
119
+
120
+ Reaching Node's built-in `fetch` from a userland undici relies on both writing the legacy `Symbol.for('undici.globalDispatcher.1')` slot. That is an implicit cross-version coupling, not a contract — see [corepack#834](https://github.com/nodejs/corepack/issues/834) for it breaking. `tests/install.spec.ts` asserts a real request reaches a loopback proxy, so a version bump that breaks the coupling fails there rather than in the field.
121
+
122
+ </details>
package/README.zh.md ADDED
@@ -0,0 +1,122 @@
1
+ ---
2
+ description: "Harness 的出站 HTTP 代理支持:从启动环境解析出的一份策略,如何覆盖到 Node fetch 本来会直连的每一个请求。"
3
+ kind: "package-reference"
4
+ ---
5
+
6
+ # @qilin-agent/http-proxy
7
+
8
+ [English](README.md) | 中文
9
+
10
+ ## 概述
11
+
12
+ 使用本包可为采用 Node 内置 `fetch` 的 Harness 请求应用一份出站 HTTP 代理策略,包括 LLM(大语言模型)、web 搜索与 HTTP MCP 流量。启动器只读取一次标准代理环境变量,普通 `fetch` 调用方无需额外引入或改动。loopback 流量保持直连;不受支持的代理 URL 会被报告,并针对受影响的协议跳过。公共辅助函数可让调用方路由采用自有代理设置的传输、准备子进程环境,或为隔离回放清除代理变量。
13
+
14
+ ## 目录
15
+
16
+ - [使用本包](#use-this-package)
17
+ - [理解实现](#understand-the-implementation)
18
+ - [进一步探索](#further-exploration)
19
+ - [模型体验](#model-experience)
20
+ - [已知限制与延期工作](#known-limitations-and-deferred-work)
21
+ - [开发备注](#dev-note)
22
+
23
+ -----
24
+
25
+ <a id="use-this-package"></a>
26
+ ## 使用本包
27
+
28
+ 无需挂载,也无需配置。`qilin` 启动器会在第一个插件加载之前,为每个 profile 解析并安装策略,因此导出了 `HTTPS_PROXY` 的用户在所有位置都会走代理。本包是库而非插件,因为传输策略每个进程只有一个答案:没有第二个实现可替换,也没有比进程更窄的作用域可赋予。
29
+
30
+ ### 编写新的出站调用
31
+
32
+ 普通 `fetch()` 会走代理,任何使用 `globalThis.fetch` 的 SDK 也一样——MCP HTTP 传输与 pi-ai 提供方栈都使用它。应验证每个 SDK 的实际传输;例外见[已知限制与延后工作](#known-limitations-and-deferred-work)。
33
+
34
+ | 你要写的东西 | 使用 |
35
+ |---|---|
36
+ | 普通请求,或最终落到 `globalThis.fetch` 的 SDK | 什么都不用——全局 dispatcher 已经在路由它 |
37
+ | 需要按“这次请求是否走代理”分支的调用 | `proxyRouteFor(url)` |
38
+ | 接受自有代理 URL 的 SDK | `proxyRouteFor(url)`,把 `route.proxy` 传进去 |
39
+ | 由你自己构造环境的 spawn | 把 `proxyEnvironmentForChild()` 应用到该 spawn(`undefined` 表示删除) |
40
+ | 必须连到自带 fixture(测试前置数据)服务器的 harness | 把 `clearedProxyEnv()` 应用到该 spawn |
41
+
42
+ `proxyRouteFor` 给出的不只是答案,还有该答案所假定的传输:走代理的那一支携带着此刻正按该策略路由的 dispatcher。若调用方先读策略、再自建传输,卸载就可能落在两次读取之间,把请求发往其分支从未放行的去处。
43
+
44
+ 构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法。有一处调用点确实自有传输——`web-fetch-http` 会把请求钉在它已校验过的地址上,而这是进程级 dispatcher 无法承载的单次请求状态——它在该行用 `proxy-exempt:` 注释说明。
45
+
46
+ 该门禁看不进 SDK 内部,因此每个出网点都配有 `egress.spec.ts`,通过假代理驱动实际传输并检查观察到的路由。每个新的出网点必须包含该传输测试。遥测断言其直连例外。这些测试可发现调用点不变但依赖变更改变路由的情况。
47
+
48
+ ### 策略读取哪些值
49
+
50
+ `http_proxy`、`https_proxy`、`no_proxy` 与 `all_proxy`,小写优先、大写兜底,空值视为未设置。`ALL_PROXY` 为两种协议兜底,HTTPS 最后回退到 HTTP 代理——其中第一条 Node 与 undici 都不会自行推导。取值来自启动器的快照:先看导出的环境变量,再看 `$QILIN_HOME/.env`。项目自己的 `.env` 不能携带这些名字——那个文件随 clone 一起到来,启动器宁可拒绝启动,也不让一个仓库决定 Harness 把流量发往何处。
51
+
52
+ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、`0.0.0.0`,以及它们的 IPv4 映射写法。否则 Harness 自己的 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。发布出去的绕过列表只包含读取环境的消费者能匹配的四个字面量条目;`proxyForUrl` 自行识别整个网段,因为列表条目无法表达一个范围。
53
+
54
+ ### 失败处理
55
+
56
+ 本包无法使用的代理值——SOCKS 或 PAC URL、无法解析的字符串、不受支持的协议——会被报告并跳过,该 scheme 转为直连。该变量可能是用户为其他工具导出的,不应因此阻止 agent(智能体)启动。
57
+
58
+ -----
59
+
60
+ <a id="understand-the-implementation"></a>
61
+ ## 理解实现
62
+
63
+ ### 设计理念
64
+
65
+ **一次解析,一个匹配器。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `qilin-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此该 dispatcher 是一个 `Agent`,其按 origin 调用的 `factory` 自身调用 `proxyForUrl()`,不存在可能与第一个解析器产生漂移的第二个解析器。undici 的 `EnvHttpProxyAgent` 在此无法胜任:没有 `HTTPS_PROXY` 时它让 `https:` 复用 HTTP 代理,于是本包在拒绝用户为该 scheme 指定的 URL 后本应保持直连的 scheme 仍会被隧道转发。
66
+
67
+ **子进程继承用户自己的值,以及用户未设置部分的解析结果。** 用户以任一大小写指定过的 scheme,会以他们书写的形式原样传给子进程,因此用户为 `curl` 设置的 SOCKS 代理绝不会被替换成为其他 scheme 指定的 HTTP 代理。两种大小写都未指定的 scheme 则携带解析值,否则子进程的路由会与父进程分歧:Node 的 `NODE_USE_ENV_PROXY` 不读 `ALL_PROXY`。绕过列表始终采用解析结果——它只会追加 loopback 条目,用户写下的内容不会丢失。让父子进程只有一个路由答案的代价是:`curl` 也会看到本包由 HTTP 代理推导出的 `https:` 代理。有一处例外是为了保护子进程自身:当子进程收到的某个值是本包拒绝过的——比如为 `curl` 保留的 SOCKS URL——就不再设置 `NODE_USE_ENV_PROXY`,因为 Node 在该标志下会在运行程序之前先解析 `HTTP_PROXY` 与 `HTTPS_PROXY`,遇到这类值直接退出。此时子 Node 直连(本进程已为该协议如此报告),而不是根本起不来。
68
+
69
+ ### 源码地图
70
+
71
+ | 文件 | 承载 |
72
+ |---|---|
73
+ | `src/policy.ts` | 解析与绕过匹配;诊断只点名变量,从不带出它的值。不引入任何传输实现,因此在没有 undici 的环境中仍可加载。 |
74
+ | `src/install.ts` | 全局 dispatcher、生效策略记录、路由与子进程环境。动态引入 undici。 |
75
+ | `src/index.ts` | 本包的对外接口:四个函数与一个类型。 |
76
+
77
+ ### 绕过匹配
78
+
79
+ 一个条目写的是主机名,它连同其下所有子域名一起匹配:`NO_PROXY=example.com` 也会放行 `api.example.com`。前缀 `.` 或 `*.` 可以写,含义相同。条目可带 `:port`,`*` 则放行全部。带方括号与裸写的 IPv6 字面量都能匹配——裸写的 `::1` **不会**被读成主机 `:` 端口 `1`,而 undici 自带的匹配器正是这样出错的,这也是解析结果中同时携带 `::1` 与 `[::1]` 的原因。CIDR 不参与匹配:操作系统的绕过列表常含 `10.0.0.0/8`,必须改写成后缀形式。
80
+
81
+ -----
82
+
83
+ <a id="further-exploration"></a>
84
+ ## 进一步探索
85
+
86
+ - [网络代理指南](../../../docs/user/guide/network-proxy.zh.md)——需要导出什么,以及为什么浏览器走代理而终端不走。
87
+ - [`qilin-web-fetch-http`](../../web/web-fetch-http/README.zh.md)——唯一一个安全规则会因代理而改变的消费方。
88
+
89
+ -----
90
+
91
+ <a id="model-experience"></a>
92
+ ## 模型体验
93
+
94
+ 无。本包只承担传输策略:它改变字节如何抵达网络,不注册任何提示词、schema 或结果文本。
95
+
96
+ #### KV Cache 影响
97
+
98
+ 不会直接失效:本包不贡献任何请求 token,也从不改变请求前缀,因此提供方缓存复用不受影响。
99
+
100
+ ## 已知限制与延期工作
101
+
102
+ <a id="known-limitations-and-deferred-work"></a>
103
+
104
+
105
+ 这些限制界定了本包不适用的场景,属于当前的包级约束。
106
+
107
+ - **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告,且该协议保持直连,不会借用另一协议的代理。
108
+ - **不支持自定义证书颁发机构**——做 TLS 拦截的企业代理需要在启动前为进程设置 `NODE_EXTRA_CA_CERTS`,本包既不设置也不校验它。
109
+ - **spawn 出的子进程只在足够新的运行时上遵循策略,且仅当它继承的每个值都是 Node 接受的**——它通过 Node 的 `NODE_USE_ENV_PROXY` 读取已发布的环境(22.21+、24+),而 engines 范围允许 22.19 与 22.20,在这两个版本上这样的子进程保持直连。若用户环境里还有 SOCKS 或其他被拒的代理,所有子 Node 都保持直连:不设置该标志,子进程才起得来。子进程还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。本进程内不依赖任何 Node 版本:每一次进程内请求都会落到全局 dispatcher。
110
+ - **遥测按设计直连**——OTLP 导出器通过 `node:http` 投递,全局 dispatcher 触及不到。要让它走代理,要么依赖 `http.Agent` 的 `proxyEnv`,而该选项晚于本项目支持的最低 Node 版本;要么改用 SDK 的 `fetch` 传输,但它没有压缩能力,而随附配置启用了 gzip。遥测是唯一一条丢失后不会让用户付出任何代价的通道,因此维持原状;`QILIN_TELEMETRY_MODE=DISABLED` 可关闭它。
111
+ - **模型编写的程序不接收代理配置**——Node ptc-runtime 进程与 workflow worker 不继承可能含有 `user:password` 的代理 URL。其直接请求需要自行配置,并继续受到执行沙箱的约束。
112
+ - **防回归门禁只看源码,看不到依赖内部**——`verify-no-bare-dispatcher` 解析 `packages/*/*/src` 与 `apps/*/src`;测试、脚本以及第三方 SDK 的内部都在其之外。这正是每个出网点还各配一份 `egress.spec.ts` 的原因。
113
+
114
+ <a id="dev-note"></a>
115
+ ### 开发备注
116
+
117
+ <details>
118
+ <summary>面向维护者的工作上下文——点击展开</summary>
119
+
120
+ userland undici 能触及 Node 内置的 `fetch`,依赖的是两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合,不是约定——参见 [corepack#834](https://github.com/nodejs/corepack/issues/834) 中它失效的实例。`tests/install.spec.ts` 断言真实请求会抵达一个 loopback 代理,因此破坏该耦合的版本升级会在那里失败,而不是流到线上。
121
+
122
+ </details>
package/lib/index.js ADDED
@@ -0,0 +1,536 @@
1
+ //#region lib/types/policy.js
2
+ /**
3
+ * Proxy policy resolution: the pure, transport-free half of this package. It turns the launch
4
+ * environment into one {@link ProxyPolicy}, and answers which proxy
5
+ * (if any) a given URL goes through.
6
+ *
7
+ * Nothing here imports `undici`, so the module stays loadable in the browser-worker runtime that
8
+ * evaluates `qilin-web-fetch-http` without a Node transport.
9
+ * @module @qilin-agent/http-proxy/policy
10
+ */
11
+ /**
12
+ * Loopback entries merged into every policy's `noProxy`. A proxy that also serves the harness's own
13
+ * loopback traffic turns the Web UI, the Connection transport, and every local test server into a
14
+ * routing loop, so the bypass is not optional.
15
+ *
16
+ * `::1` and `[::1]` are both listed because the resolved string is also handed to undici, whose
17
+ * matcher reads a bare `::1` as host `:` port `1` and therefore never bypasses it.
18
+ */
19
+ const LOOPBACK_NO_PROXY = [
20
+ "localhost",
21
+ "127.0.0.1",
22
+ "::1",
23
+ "[::1]"
24
+ ];
25
+ /**
26
+ * The environment names each policy field owns, lowercase first — undici reads the lowercase name
27
+ * first, so both casings are always written or cleared together.
28
+ */
29
+ const POLICY_ENV_NAMES = {
30
+ httpProxy: ["http_proxy", "HTTP_PROXY"],
31
+ httpsProxy: ["https_proxy", "HTTPS_PROXY"],
32
+ noProxy: ["no_proxy", "NO_PROXY"]
33
+ };
34
+ /**
35
+ * Every environment name that carries proxy configuration, including the `ALL_PROXY` fallback this
36
+ * package resolves but never writes back. A caller that must isolate a child from the machine's
37
+ * network policy clears exactly these.
38
+ */
39
+ const PROXY_ENV_NAMES = [
40
+ ...Object.values(POLICY_ENV_NAMES).flat(),
41
+ "all_proxy",
42
+ "ALL_PROXY"
43
+ ];
44
+ /** Proxy URL schemes this package routes through. Everything else is reported, never silently dropped. */
45
+ const SUPPORTED_PROTOCOLS = new Set(["http:", "https:"]);
46
+ /** Schemes recognised well enough to name in a diagnostic instead of calling them malformed. */
47
+ const SOCKS_PROTOCOLS = new Set([
48
+ "socks:",
49
+ "socks4:",
50
+ "socks4a:",
51
+ "socks5:",
52
+ "socks5h:"
53
+ ]);
54
+ /** A policy that proxies nothing. Callers that have not installed a policy resolve URLs against this. */
55
+ const DIRECT_POLICY = {
56
+ noProxy: "",
57
+ source: "none"
58
+ };
59
+ /**
60
+ * Read one environment name in undici's precedence order — lowercase first, uppercase as the
61
+ * fallback — treating a blank value as unset. Blank matters: undici's own `??` chain lets an empty
62
+ * lowercase name shadow a populated uppercase one.
63
+ *
64
+ * @param env - the launch environment snapshot to read.
65
+ * @param lower - the lowercase variable name.
66
+ * @returns the trimmed value and the name that supplied it, or `undefined` when neither is set.
67
+ */
68
+ function readEnv(env, lower) {
69
+ for (const name of [lower, lower.toUpperCase()]) {
70
+ const value = env.get(name)?.value.trim();
71
+ if (value !== void 0 && value !== "") return {
72
+ value,
73
+ name
74
+ };
75
+ }
76
+ }
77
+ /** A slot nobody filled. */
78
+ const ABSENT = { kind: "absent" };
79
+ /**
80
+ * Validate one candidate proxy URL.
81
+ *
82
+ * @param candidate - the raw value and the origin to name in a diagnostic.
83
+ * @param diagnostics - collector the rejection is appended to.
84
+ * @returns the candidate's usability, distinguishing a rejected slot from an empty one.
85
+ */
86
+ function acceptProxyUrl(candidate, diagnostics) {
87
+ if (candidate === void 0) return ABSENT;
88
+ const parsed = URL.parse(candidate.value);
89
+ if (parsed === null) {
90
+ diagnostics.push({
91
+ kind: "invalid",
92
+ origin: candidate.name,
93
+ message: `${candidate.name} is not a valid URL; connecting directly`
94
+ });
95
+ return { kind: "rejected" };
96
+ }
97
+ if (SOCKS_PROTOCOLS.has(parsed.protocol)) {
98
+ diagnostics.push({
99
+ kind: "socks",
100
+ origin: candidate.name,
101
+ message: `${candidate.name} names a SOCKS proxy, which is not supported; connecting directly for that scheme — set an http:// or https:// proxy URL instead`
102
+ });
103
+ return { kind: "rejected" };
104
+ }
105
+ if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) {
106
+ diagnostics.push({
107
+ kind: "invalid",
108
+ origin: candidate.name,
109
+ message: `${candidate.name} uses the unsupported ${parsed.protocol}// scheme; connecting directly for that scheme — set an http:// or https:// proxy URL instead`
110
+ });
111
+ return { kind: "rejected" };
112
+ }
113
+ return {
114
+ kind: "accepted",
115
+ value: candidate.value
116
+ };
117
+ }
118
+ /**
119
+ * Whether a proxy URL is one this package accepts: parseable, with an `http:` or `https:` scheme.
120
+ * The same test {@link acceptProxyUrl} applies, without its diagnostics.
121
+ *
122
+ * @param value - the proxy URL as an environment variable holds it.
123
+ * @returns true when the URL would be accepted.
124
+ */
125
+ function isSupportedProxyUrl(value) {
126
+ const parsed = URL.parse(value);
127
+ return parsed !== null && SUPPORTED_PROTOCOLS.has(parsed.protocol);
128
+ }
129
+ /**
130
+ * Resolve one scheme's proxy from its own slot, then the fallbacks — but only when the scheme's own
131
+ * slot was empty. A rejected slot keeps that scheme direct, so the diagnostic and the route agree.
132
+ *
133
+ * @param own - what the scheme's own name supplied.
134
+ * @param fallbacks - values to try in order when `own` is absent.
135
+ * @returns the proxy URL for that scheme, or `undefined` for a direct connection.
136
+ */
137
+ function resolveScheme(own, ...fallbacks) {
138
+ if (own.kind === "accepted") return own.value;
139
+ if (own.kind === "rejected") return void 0;
140
+ return fallbacks.find((value) => value !== void 0);
141
+ }
142
+ /**
143
+ * Merge {@link LOOPBACK_NO_PROXY} into a bypass list, preserving the caller's entries and order.
144
+ * A list of `*` already bypasses everything and is returned unchanged.
145
+ *
146
+ * @param noProxy - the bypass list as the environment supplied it.
147
+ * @returns the effective bypass list.
148
+ */
149
+ function withLoopback(noProxy) {
150
+ const entries = (noProxy ?? "").split(/[,\s]+/).map((entry) => entry.trim()).filter((entry) => entry !== "");
151
+ if (entries.includes("*")) return "*";
152
+ const present = new Set(entries.map((entry) => entry.toLowerCase()));
153
+ return [...entries, ...LOOPBACK_NO_PROXY.filter((entry) => !present.has(entry))].join(",");
154
+ }
155
+ /**
156
+ * Split one bypass entry into host and optional port.
157
+ *
158
+ * A bare IPv6 literal carries several colons and no port, so only a single-colon entry splits;
159
+ * a bracketed literal takes its port from after the bracket. Getting this wrong is how undici
160
+ * turns `::1` into host `:` port `1`.
161
+ *
162
+ * @param entry - one already-trimmed bypass entry.
163
+ * @returns the entry's host and, when it carries one, its port.
164
+ */
165
+ function splitHostPort(entry) {
166
+ if (entry.startsWith("[")) {
167
+ const close = entry.indexOf("]");
168
+ if (close !== -1) {
169
+ const rest = entry.slice(close + 1);
170
+ const host = entry.slice(1, close);
171
+ return rest.startsWith(":") ? {
172
+ host,
173
+ port: rest.slice(1)
174
+ } : { host };
175
+ }
176
+ }
177
+ const colon = entry.indexOf(":");
178
+ if (colon !== -1 && entry.indexOf(":", colon + 1) === -1) return {
179
+ host: entry.slice(0, colon),
180
+ port: entry.slice(colon + 1)
181
+ };
182
+ return { host: entry };
183
+ }
184
+ /** One IPv4 octet, so a loopback match cannot accept `127.999.1.1`. */
185
+ const OCTET = "(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)";
186
+ /** The whole `127.0.0.0/8` block, not just its first address. */
187
+ const LOOPBACK_IPV4 = new RegExp(`^127\\.${OCTET}\\.${OCTET}\\.${OCTET}$`);
188
+ /**
189
+ * Whether a host names this machine.
190
+ *
191
+ * A proxy cannot meaningfully reach one: it would resolve the address in its own network, and a
192
+ * proxy running on this machine would reach a service that only listens on loopback. The bypass
193
+ * list carries {@link LOOPBACK_NO_PROXY} for the consumers that read an environment rather than a
194
+ * policy, but those are four literal entries — matching them alone leaves `127.0.0.2`, the whole
195
+ * rest of `127.0.0.0/8`, and the IPv4-mapped spelling routed through the proxy.
196
+ *
197
+ * @param hostname - a URL's hostname, bracketed or not.
198
+ * @returns true when the host is loopback or the unspecified address.
199
+ */
200
+ function isLoopbackHost(hostname) {
201
+ const host = hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
202
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
203
+ if (host === "::1" || host === "::" || host === "0.0.0.0") return true;
204
+ const mappedHigh = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/.exec(host)?.[1];
205
+ if (mappedHigh !== void 0) return Number.parseInt(mappedHigh, 16) >>> 8 === 127;
206
+ return LOOPBACK_IPV4.test(host.startsWith("::ffff:") ? host.slice(7) : host);
207
+ }
208
+ /**
209
+ * Decide whether a bypass list exempts one URL. An entry names a host and matches it together with
210
+ * every subdomain under it — `example.com` also bypasses `api.example.com` — and a leading `.` or
211
+ * `*.` is accepted as the same thing; an entry may carry a `:port`, and `*` bypasses everything.
212
+ * CIDR notation is not matched —
213
+ * an operating system's bypass list often carries `10.0.0.0/8`, which must be rewritten as suffixes.
214
+ *
215
+ * @param noProxy - the effective bypass list.
216
+ * @param url - the request URL.
217
+ * @returns true when the URL must bypass the proxy.
218
+ */
219
+ function bypassesProxy(noProxy, url) {
220
+ const host = url.hostname.replace(/^\[|\]$/g, "").replace(/\.$/, "").toLowerCase();
221
+ const port = url.port !== "" ? url.port : url.protocol === "https:" ? "443" : "80";
222
+ for (const raw of noProxy.split(/[,\s]+/)) {
223
+ const entry = raw.trim().toLowerCase();
224
+ if (entry === "") continue;
225
+ if (entry === "*") return true;
226
+ const split = splitHostPort(entry);
227
+ if (split.port !== void 0 && split.port !== port) continue;
228
+ const candidate = split.host.replace(/^\*?\./, "").replace(/\.$/, "");
229
+ if (candidate === "") continue;
230
+ if (host === candidate || host.endsWith(`.${candidate}`)) return true;
231
+ }
232
+ return false;
233
+ }
234
+ /**
235
+ * Resolve the outbound proxy policy for this process.
236
+ *
237
+ * A scheme's own variable wins, then `ALL_PROXY`, then — for HTTPS only — the HTTP proxy, matching
238
+ * undici so this function and the installed dispatcher never disagree about one URL.
239
+ *
240
+ * @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
241
+ * @returns the policy to install plus every rejected candidate.
242
+ */
243
+ function resolveProxyPolicy(env) {
244
+ const diagnostics = [];
245
+ const all = acceptProxyUrl(readEnv(env, "all_proxy"), diagnostics);
246
+ const allValue = all.kind === "accepted" ? all.value : void 0;
247
+ const envHttp = acceptProxyUrl(readEnv(env, "http_proxy"), diagnostics);
248
+ const envHttps = acceptProxyUrl(readEnv(env, "https_proxy"), diagnostics);
249
+ const httpProxy = resolveScheme(envHttp, allValue);
250
+ const httpsProxy = resolveScheme(envHttps, allValue, httpProxy);
251
+ if (httpProxy === void 0 && httpsProxy === void 0) return {
252
+ policy: DIRECT_POLICY,
253
+ diagnostics
254
+ };
255
+ return {
256
+ policy: {
257
+ ...httpProxy === void 0 ? {} : { httpProxy },
258
+ ...httpsProxy === void 0 ? {} : { httpsProxy },
259
+ noProxy: withLoopback(readEnv(env, "no_proxy")?.value),
260
+ source: "env"
261
+ },
262
+ diagnostics
263
+ };
264
+ }
265
+ /**
266
+ * Resolve which proxy one URL goes through under a policy.
267
+ *
268
+ * This is the single answer both the installed dispatcher and `qilin-web-fetch-http` consult, so a URL
269
+ * can never be pinned to a resolved address by one and tunnelled by the other.
270
+ *
271
+ * @param policy - the active policy.
272
+ * @param url - the request URL.
273
+ * @returns the proxy URL to tunnel through, or `undefined` for a direct connection.
274
+ */
275
+ function proxyForUrl(policy, url) {
276
+ const proxy = url.protocol === "https:" ? policy.httpsProxy : url.protocol === "http:" ? policy.httpProxy : void 0;
277
+ if (proxy === void 0) return void 0;
278
+ if (isLoopbackHost(url.hostname)) return void 0;
279
+ return bypassesProxy(policy.noProxy, url) ? void 0 : proxy;
280
+ }
281
+ //#endregion
282
+ //#region lib/types/install.js
283
+ /**
284
+ * Proxy installation: the transport half of this package. It owns undici's global dispatcher and the
285
+ * process-wide record of which policy is active.
286
+ *
287
+ * `undici` is imported dynamically so the pure {@link ProxyPolicy} half stays loadable where no Node
288
+ * transport exists, matching how `qilin-web-fetch-http` defers its own transport import.
289
+ * @module @qilin-agent/http-proxy/install
290
+ */
291
+ /** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */
292
+ let active;
293
+ /**
294
+ * The proxy environment as the user exported it, or `undefined` when no policy is installed.
295
+ *
296
+ * Owned by the OUTERMOST install: one layered over the launcher's would otherwise record the outer
297
+ * policy's published values as if the user had written them, and
298
+ * hand every child a normalization the user never asked for.
299
+ *
300
+ * {@link proxyEnvironmentForChild} keeps a value the user set rather than the one this process resolved from
301
+ * it, so a SOCKS proxy `curl` can use is not replaced by an HTTP proxy named for another scheme.
302
+ */
303
+ let inheritedProxyEnv;
304
+ /** The dispatcher installed with {@link active}, so a route can hand back the one already routing. */
305
+ let installed;
306
+ /** A route that sends nothing through a proxy, shared because it carries no per-request state. */
307
+ const DIRECT_ROUTE = { proxied: false };
308
+ /**
309
+ * Decide how to send one request, and hand back the transport that decision assumed.
310
+ *
311
+ * @param url - the request URL.
312
+ * @returns the proxied route with its proxy URL and dispatcher, or the direct route.
313
+ */
314
+ function proxyRouteFor(url) {
315
+ const policy = active;
316
+ const dispatcher = installed;
317
+ if (policy === void 0 || dispatcher === void 0) return DIRECT_ROUTE;
318
+ const proxy = proxyForUrl(policy, url);
319
+ return proxy === void 0 ? DIRECT_ROUTE : {
320
+ proxied: true,
321
+ proxy,
322
+ dispatcher
323
+ };
324
+ }
325
+ /**
326
+ * Publish a policy through the proxy environment variables, which is how the consumers that read an
327
+ * environment rather than a policy object — `node:http`'s `proxyEnv` and every spawned child — see
328
+ * the one resolved answer, including the `ALL_PROXY` fallback and the merged loopback bypass that
329
+ * neither derives on its own. The global dispatcher does not read these; it routes by the policy.
330
+ *
331
+ * @param policy - the policy to publish.
332
+ * @returns a function restoring every name this call changed.
333
+ */
334
+ function applyPolicyEnv(policy) {
335
+ const previousInherited = inheritedProxyEnv;
336
+ inheritedProxyEnv = previousInherited ?? snapshotProxyEnv();
337
+ const published = {};
338
+ for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
339
+ const value = policy[field];
340
+ for (const name of names) published[name] = value;
341
+ }
342
+ const restore = writeProxyEnv(published);
343
+ return () => {
344
+ restore();
345
+ inheritedProxyEnv = previousInherited;
346
+ };
347
+ }
348
+ /**
349
+ * Read every proxy name this package publishes, as `process.env` holds it now.
350
+ *
351
+ * @returns one entry per name in {@link POLICY_ENV_NAMES}; `undefined` marks an absent name.
352
+ */
353
+ function snapshotProxyEnv() {
354
+ const snapshot = {};
355
+ for (const names of Object.values(POLICY_ENV_NAMES)) for (const name of names) snapshot[name] = process.env[name];
356
+ return snapshot;
357
+ }
358
+ /**
359
+ * Set every proxy name to the value `values` holds for it, removing a name whose value is `undefined`.
360
+ *
361
+ * @param values - the value each name in {@link POLICY_ENV_NAMES} should hold.
362
+ * @returns a function restoring every name to what it held before this call.
363
+ */
364
+ function writeProxyEnv(values) {
365
+ const previous = snapshotProxyEnv();
366
+ for (const name of Object.keys(previous)) {
367
+ const value = values[name];
368
+ if (value === void 0) Reflect.deleteProperty(process.env, name);
369
+ else process.env[name] = value;
370
+ }
371
+ return () => {
372
+ for (const [name, value] of Object.entries(previous)) if (value === void 0) Reflect.deleteProperty(process.env, name);
373
+ else process.env[name] = value;
374
+ };
375
+ }
376
+ /**
377
+ * Build the global dispatcher for one policy.
378
+ *
379
+ * Routing runs through {@link proxyForUrl} per origin, so `fetch` and every caller that asks where a
380
+ * URL goes read the same answer from the same matcher. undici's `EnvHttpProxyAgent` cannot express
381
+ * this policy: with no `HTTPS_PROXY` present it reuses the HTTP proxy for `https:`, which would
382
+ * tunnel a scheme this package deliberately keeps direct after refusing the SOCKS or malformed URL
383
+ * the user named for it — the route and the diagnostic would then disagree.
384
+ *
385
+ * @param policy - the policy to route by; it must proxy at least one scheme.
386
+ * @returns the dispatcher to install, owning every per-origin agent its factory created.
387
+ */
388
+ async function createPolicyDispatcher(policy) {
389
+ const { Agent, Pool, ProxyAgent } = await import("undici");
390
+ return new Agent({ factory(origin, options) {
391
+ const passed = options;
392
+ const proxy = proxyForUrl(policy, new URL(origin.toString()));
393
+ if (proxy !== void 0) return new ProxyAgent({
394
+ ...passed,
395
+ uri: proxy
396
+ });
397
+ return new Pool(origin, passed);
398
+ } });
399
+ }
400
+ /**
401
+ * Route this process's outbound HTTP through `policy`.
402
+ *
403
+ * Installing replaces undici's global dispatcher, which is what Node's built-in `fetch` resolves, so
404
+ * every caller that issues a plain `fetch()` is covered without knowing this package exists. A policy
405
+ * that proxies nothing installs a direct dispatcher and leaves the environment untouched.
406
+ *
407
+ * A worker thread has its own `globalThis` and so its own dispatcher; installing here does not
408
+ * reach it. No worker installs one today: both this repository ships — the workflow engine and the
409
+ * PTC runtime — evaluate model-authored scripts, which must not receive a proxy URL that may carry
410
+ * credentials. A worker that needs the policy has to be handed one explicitly and install it itself.
411
+ *
412
+ * @param policy - the resolved policy to install.
413
+ * @returns a disposer restoring the previous dispatcher, policy, and environment, then closing the agent.
414
+ */
415
+ async function installGlobalProxy(policy) {
416
+ const previousPolicy = active;
417
+ if (policy.source === "none") {
418
+ if (previousPolicy === void 0) {
419
+ active = policy;
420
+ return () => {
421
+ active = previousPolicy;
422
+ return Promise.resolve();
423
+ };
424
+ }
425
+ const previousInstalled = installed;
426
+ const restoreEnv = inheritedProxyEnv === void 0 ? void 0 : writeProxyEnv(inheritedProxyEnv);
427
+ const undici = await import("undici");
428
+ const previous = undici.getGlobalDispatcher();
429
+ const direct = new undici.Agent();
430
+ undici.setGlobalDispatcher(direct);
431
+ active = policy;
432
+ installed = void 0;
433
+ return async () => {
434
+ undici.setGlobalDispatcher(previous);
435
+ active = previousPolicy;
436
+ installed = previousInstalled;
437
+ restoreEnv?.();
438
+ await direct.close();
439
+ };
440
+ }
441
+ const restoreEnv = applyPolicyEnv(policy);
442
+ const { getGlobalDispatcher, setGlobalDispatcher } = await import("undici");
443
+ const previousDispatcher = getGlobalDispatcher();
444
+ const previousInstalled = installed;
445
+ const agent = await createPolicyDispatcher(policy);
446
+ setGlobalDispatcher(agent);
447
+ active = policy;
448
+ installed = agent;
449
+ return async () => {
450
+ setGlobalDispatcher(previousDispatcher);
451
+ active = previousPolicy;
452
+ installed = previousInstalled;
453
+ restoreEnv();
454
+ await agent.close();
455
+ };
456
+ }
457
+ /**
458
+ * The proxy environment a spawned child needs.
459
+ *
460
+ * A child inherits the parent environment, which this process rewrote to its own resolved policy.
461
+ * Handing that normalization straight through would replace values the user set for other tools, so
462
+ * each proxy name the user exported is restored to what they wrote: a SOCKS proxy `curl` uses is
463
+ * not swapped for the HTTP one this package fell back to for that scheme.
464
+ *
465
+ * A scheme the user named in neither casing carries the resolved value instead of being removed.
466
+ * Without that the child's routing silently diverges from its parent's: `NODE_USE_ENV_PROXY` does
467
+ * not read `ALL_PROXY`, so a child of a parent that resolved its proxy from that name would connect
468
+ * directly while the parent proxies.
469
+ *
470
+ * The bypass list is always the resolved one. It only ever adds the loopback entries to what
471
+ * the user wrote, so nothing is lost, and the child stops sending its own localhost traffic to a
472
+ * proxy that cannot route it.
473
+ *
474
+ * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that child direct. Such a child
475
+ * also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in
476
+ * their separators and IPv4-range support. Non-Node children (curl, git, pnpm) ignore the flag and
477
+ * read the variables themselves.
478
+ *
479
+ * The flag is withheld when a proxy value the child receives is one this package refused. Node
480
+ * parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program, and exits on a
481
+ * scheme other than `http:` or `https:` — so a SOCKS value kept for `curl` would stop every Node
482
+ * child from starting. Without the flag such a child connects directly, as this process already
483
+ * reported for that scheme, and `curl` still reads the value it was kept for.
484
+ *
485
+ * A worker thread is deliberately NOT served here — see the workflow engine, which runs
486
+ * model-authored scripts and must not receive a proxy URL that may carry credentials.
487
+ *
488
+ * @returns names to apply to the child environment, where `undefined` means remove, or an empty
489
+ * object when no proxy is active.
490
+ */
491
+ function proxyEnvironmentForChild() {
492
+ const policy = active;
493
+ const inherited = inheritedProxyEnv;
494
+ if (policy === void 0 || policy.source === "none" || inherited === void 0) return {};
495
+ const overlay = { NODE_USE_ENV_PROXY: "1" };
496
+ for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) {
497
+ const resolved = policy[field];
498
+ const named = field !== "noProxy" && names.some((name) => inherited[name] !== void 0);
499
+ for (const name of names) overlay[name] = named ? inherited[name] : resolved;
500
+ }
501
+ if ([...POLICY_ENV_NAMES.httpProxy, ...POLICY_ENV_NAMES.httpsProxy].some((name) => overlay[name] !== void 0 && !isSupportedProxyUrl(overlay[name]))) delete overlay.NODE_USE_ENV_PROXY;
502
+ return overlay;
503
+ }
504
+ /**
505
+ * Resolve this process's proxy policy from `env` and install it.
506
+ *
507
+ * Resolution, reporting, and installation are one operation because no caller needs them apart: the
508
+ * launcher does all three in sequence before the first plugin mounts, and a policy resolved but not
509
+ * installed routes nothing.
510
+ *
511
+ * A value the environment supplies but this package cannot use is reported and skipped rather than
512
+ * thrown: the variable may have been exported for another tool, and a proxy the harness cannot use
513
+ * must not stop the agent from starting.
514
+ *
515
+ * @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
516
+ * @param report - receives one message per rejected value, in the order the values were considered.
517
+ * @returns a disposer restoring the previous dispatcher, policy, and environment.
518
+ */
519
+ async function installProxyFromEnvironment(env, report) {
520
+ const { policy, diagnostics } = resolveProxyPolicy(env);
521
+ for (const diagnostic of diagnostics) report(diagnostic.message);
522
+ return await installGlobalProxy(policy);
523
+ }
524
+ /**
525
+ * The environment overlay that removes every proxy name from a spawned child.
526
+ *
527
+ * A harness that replays a recorded session must reach its own fixture server, not the proxy a
528
+ * developer or a CI runner exported; `undefined` is how a spawn removes a name it inherits.
529
+ *
530
+ * @returns one entry per proxy name, each `undefined`.
531
+ */
532
+ function clearedProxyEnv() {
533
+ return Object.fromEntries(PROXY_ENV_NAMES.map((name) => [name, void 0]));
534
+ }
535
+ //#endregion
536
+ export { clearedProxyEnv, installProxyFromEnvironment, proxyEnvironmentForChild, proxyRouteFor };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Outbound HTTP proxy support for QiLin.
3
+ *
4
+ * Node's built-in `fetch` ignores `HTTP_PROXY` and friends, so every harness request would connect
5
+ * directly no matter what the user exported. The launcher resolves one policy from the launch
6
+ * environment and installs it as undici's global dispatcher, which is what `fetch` resolves — so
7
+ * LLM adapters, web search, MCP over HTTP, and telemetry are covered without touching their code.
8
+ *
9
+ * This is a library, not a plugin: transport policy has one answer per process, so there is nothing
10
+ * for a composition to mount, swap, or scope.
11
+ *
12
+ * Four functions, one per way a caller needs the policy — install it, ask how to send one request,
13
+ * build a child's environment, and strip the ambient one for a replay.
14
+ * @module @qilin-agent/http-proxy
15
+ */
16
+ export { clearedProxyEnv, installProxyFromEnvironment, proxyEnvironmentForChild, proxyRouteFor, type ProxyRoute, } from './install.ts';
17
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Proxy installation: the transport half of this package. It owns undici's global dispatcher and the
3
+ * process-wide record of which policy is active.
4
+ *
5
+ * `undici` is imported dynamically so the pure {@link ProxyPolicy} half stays loadable where no Node
6
+ * transport exists, matching how `qilin-web-fetch-http` defers its own transport import.
7
+ * @module @qilin-agent/http-proxy/install
8
+ */
9
+ import type { Dispatcher } from 'undici';
10
+ import { type EnvLookup } from './policy.ts';
11
+ /**
12
+ * How this process must send one request.
13
+ *
14
+ * A caller that branches on the answer needs the transport that answer assumed, or an install or
15
+ * disposal landing between the two would send the request somewhere the branch did not clear. The
16
+ * proxied arm therefore carries the dispatcher already routing by this policy: it is process-wide
17
+ * and long-lived, so a caller uses it and never closes it. Disposal closes that dispatcher rather
18
+ * than destroying it, so a request already dispatched when a policy is unmounted still finishes.
19
+ */
20
+ export type ProxyRoute = {
21
+ readonly proxied: true;
22
+ readonly proxy: string;
23
+ readonly dispatcher: Dispatcher;
24
+ } | {
25
+ readonly proxied: false;
26
+ };
27
+ /**
28
+ * Decide how to send one request, and hand back the transport that decision assumed.
29
+ *
30
+ * @param url - the request URL.
31
+ * @returns the proxied route with its proxy URL and dispatcher, or the direct route.
32
+ */
33
+ export declare function proxyRouteFor(url: URL): ProxyRoute;
34
+ /**
35
+ * The proxy environment a spawned child needs.
36
+ *
37
+ * A child inherits the parent environment, which this process rewrote to its own resolved policy.
38
+ * Handing that normalization straight through would replace values the user set for other tools, so
39
+ * each proxy name the user exported is restored to what they wrote: a SOCKS proxy `curl` uses is
40
+ * not swapped for the HTTP one this package fell back to for that scheme.
41
+ *
42
+ * A scheme the user named in neither casing carries the resolved value instead of being removed.
43
+ * Without that the child's routing silently diverges from its parent's: `NODE_USE_ENV_PROXY` does
44
+ * not read `ALL_PROXY`, so a child of a parent that resolved its proxy from that name would connect
45
+ * directly while the parent proxies.
46
+ *
47
+ * The bypass list is always the resolved one. It only ever adds the loopback entries to what
48
+ * the user wrote, so nothing is lost, and the child stops sending its own localhost traffic to a
49
+ * proxy that cannot route it.
50
+ *
51
+ * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that child direct. Such a child
52
+ * also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in
53
+ * their separators and IPv4-range support. Non-Node children (curl, git, pnpm) ignore the flag and
54
+ * read the variables themselves.
55
+ *
56
+ * The flag is withheld when a proxy value the child receives is one this package refused. Node
57
+ * parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program, and exits on a
58
+ * scheme other than `http:` or `https:` — so a SOCKS value kept for `curl` would stop every Node
59
+ * child from starting. Without the flag such a child connects directly, as this process already
60
+ * reported for that scheme, and `curl` still reads the value it was kept for.
61
+ *
62
+ * A worker thread is deliberately NOT served here — see the workflow engine, which runs
63
+ * model-authored scripts and must not receive a proxy URL that may carry credentials.
64
+ *
65
+ * @returns names to apply to the child environment, where `undefined` means remove, or an empty
66
+ * object when no proxy is active.
67
+ */
68
+ export declare function proxyEnvironmentForChild(): Readonly<Record<string, string | undefined>>;
69
+ /**
70
+ * Resolve this process's proxy policy from `env` and install it.
71
+ *
72
+ * Resolution, reporting, and installation are one operation because no caller needs them apart: the
73
+ * launcher does all three in sequence before the first plugin mounts, and a policy resolved but not
74
+ * installed routes nothing.
75
+ *
76
+ * A value the environment supplies but this package cannot use is reported and skipped rather than
77
+ * thrown: the variable may have been exported for another tool, and a proxy the harness cannot use
78
+ * must not stop the agent from starting.
79
+ *
80
+ * @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
81
+ * @param report - receives one message per rejected value, in the order the values were considered.
82
+ * @returns a disposer restoring the previous dispatcher, policy, and environment.
83
+ */
84
+ export declare function installProxyFromEnvironment(env: EnvLookup, report: (message: string) => void): Promise<() => Promise<void>>;
85
+ /**
86
+ * The environment overlay that removes every proxy name from a spawned child.
87
+ *
88
+ * A harness that replays a recorded session must reach its own fixture server, not the proxy a
89
+ * developer or a CI runner exported; `undefined` is how a spawn removes a name it inherits.
90
+ *
91
+ * @returns one entry per proxy name, each `undefined`.
92
+ */
93
+ export declare function clearedProxyEnv(): Record<string, undefined>;
94
+ //# sourceMappingURL=install.d.ts.map
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Proxy policy resolution: the pure, transport-free half of this package. It turns the launch
3
+ * environment into one {@link ProxyPolicy}, and answers which proxy
4
+ * (if any) a given URL goes through.
5
+ *
6
+ * Nothing here imports `undici`, so the module stays loadable in the browser-worker runtime that
7
+ * evaluates `qilin-web-fetch-http` without a Node transport.
8
+ * @module @qilin-agent/http-proxy/policy
9
+ */
10
+ /**
11
+ * The one thing resolution needs from an environment: a name in, the winning value out. The
12
+ * launcher's snapshot satisfies this structurally and is passed unchanged, so this module names no
13
+ * package to describe its input — and a test builds one from an object literal.
14
+ */
15
+ export interface EnvLookup {
16
+ /**
17
+ * Resolve one variable name.
18
+ * @param name - the variable name.
19
+ * @returns the winning entry, or `undefined` when nothing supplies it.
20
+ */
21
+ get(name: string): {
22
+ readonly value: string;
23
+ } | undefined;
24
+ }
25
+ /**
26
+ * Loopback entries merged into every policy's `noProxy`. A proxy that also serves the harness's own
27
+ * loopback traffic turns the Web UI, the Connection transport, and every local test server into a
28
+ * routing loop, so the bypass is not optional.
29
+ *
30
+ * `::1` and `[::1]` are both listed because the resolved string is also handed to undici, whose
31
+ * matcher reads a bare `::1` as host `:` port `1` and therefore never bypasses it.
32
+ */
33
+ export declare const LOOPBACK_NO_PROXY: readonly string[];
34
+ /**
35
+ * The environment names each policy field owns, lowercase first — undici reads the lowercase name
36
+ * first, so both casings are always written or cleared together.
37
+ */
38
+ export declare const POLICY_ENV_NAMES: {
39
+ readonly httpProxy: readonly ["http_proxy", "HTTP_PROXY"];
40
+ readonly httpsProxy: readonly ["https_proxy", "HTTPS_PROXY"];
41
+ readonly noProxy: readonly ["no_proxy", "NO_PROXY"];
42
+ };
43
+ /**
44
+ * Every environment name that carries proxy configuration, including the `ALL_PROXY` fallback this
45
+ * package resolves but never writes back. A caller that must isolate a child from the machine's
46
+ * network policy clears exactly these.
47
+ */
48
+ export declare const PROXY_ENV_NAMES: readonly string[];
49
+ /**
50
+ * One resolved outbound proxy policy. Plain data with no methods: worker threads receive it through
51
+ * `workerData`'s structured clone, so both sides run the identical policy rather than each re-reading
52
+ * an environment they may not share.
53
+ */
54
+ export interface ProxyPolicy {
55
+ /** Proxy for `http:` origins, or absent for a direct connection. Always a validated `http(s):` URL. */
56
+ readonly httpProxy?: string;
57
+ /** Proxy for `https:` origins, or absent for a direct connection. Always a validated `http(s):` URL. */
58
+ readonly httpsProxy?: string;
59
+ /** The bypass list, already merged with {@link LOOPBACK_NO_PROXY}. Empty when nothing is bypassed. */
60
+ readonly noProxy: string;
61
+ /** Which layer supplied the winning proxy URL; `env` when either field came from the environment. */
62
+ readonly source: 'env' | 'none';
63
+ }
64
+ /** A policy that proxies nothing. Callers that have not installed a policy resolve URLs against this. */
65
+ export declare const DIRECT_POLICY: ProxyPolicy;
66
+ /** Why one candidate proxy value was not used. Callers decide whether this warns or fails the load. */
67
+ export interface ProxyDiagnostic {
68
+ /** `socks` for a SOCKS or PAC URL this package cannot route; `invalid` for anything unparseable. */
69
+ readonly kind: 'socks' | 'invalid';
70
+ /** The environment variable that supplied the rejected value. */
71
+ readonly origin: string;
72
+ /** Operator-facing sentence naming the rejection and the way forward. Carries no credential. */
73
+ readonly message: string;
74
+ }
75
+ /** A resolved policy plus every candidate value that was rejected on the way to it. */
76
+ export interface ProxyResolution {
77
+ /** The policy to install. Never carries a rejected value. */
78
+ readonly policy: ProxyPolicy;
79
+ /** Rejections, in the order the candidates were considered. Empty on a clean resolution. */
80
+ readonly diagnostics: readonly ProxyDiagnostic[];
81
+ }
82
+ /**
83
+ * Whether a proxy URL is one this package accepts: parseable, with an `http:` or `https:` scheme.
84
+ * The same test {@link acceptProxyUrl} applies, without its diagnostics.
85
+ *
86
+ * @param value - the proxy URL as an environment variable holds it.
87
+ * @returns true when the URL would be accepted.
88
+ */
89
+ export declare function isSupportedProxyUrl(value: string): boolean;
90
+ /**
91
+ * Whether a host names this machine.
92
+ *
93
+ * A proxy cannot meaningfully reach one: it would resolve the address in its own network, and a
94
+ * proxy running on this machine would reach a service that only listens on loopback. The bypass
95
+ * list carries {@link LOOPBACK_NO_PROXY} for the consumers that read an environment rather than a
96
+ * policy, but those are four literal entries — matching them alone leaves `127.0.0.2`, the whole
97
+ * rest of `127.0.0.0/8`, and the IPv4-mapped spelling routed through the proxy.
98
+ *
99
+ * @param hostname - a URL's hostname, bracketed or not.
100
+ * @returns true when the host is loopback or the unspecified address.
101
+ */
102
+ export declare function isLoopbackHost(hostname: string): boolean;
103
+ /**
104
+ * Decide whether a bypass list exempts one URL. An entry names a host and matches it together with
105
+ * every subdomain under it — `example.com` also bypasses `api.example.com` — and a leading `.` or
106
+ * `*.` is accepted as the same thing; an entry may carry a `:port`, and `*` bypasses everything.
107
+ * CIDR notation is not matched —
108
+ * an operating system's bypass list often carries `10.0.0.0/8`, which must be rewritten as suffixes.
109
+ *
110
+ * @param noProxy - the effective bypass list.
111
+ * @param url - the request URL.
112
+ * @returns true when the URL must bypass the proxy.
113
+ */
114
+ export declare function bypassesProxy(noProxy: string, url: URL): boolean;
115
+ /**
116
+ * Resolve the outbound proxy policy for this process.
117
+ *
118
+ * A scheme's own variable wins, then `ALL_PROXY`, then — for HTTPS only — the HTTP proxy, matching
119
+ * undici so this function and the installed dispatcher never disagree about one URL.
120
+ *
121
+ * @param env - the launch environment, whose own layering already prefers real variables over `.env` files.
122
+ * @returns the policy to install plus every rejected candidate.
123
+ */
124
+ export declare function resolveProxyPolicy(env: EnvLookup): ProxyResolution;
125
+ /**
126
+ * Resolve which proxy one URL goes through under a policy.
127
+ *
128
+ * This is the single answer both the installed dispatcher and `qilin-web-fetch-http` consult, so a URL
129
+ * can never be pinned to a resolved address by one and tunnelled by the other.
130
+ *
131
+ * @param policy - the active policy.
132
+ * @param url - the request URL.
133
+ * @returns the proxy URL to tunnel through, or `undefined` for a direct connection.
134
+ */
135
+ export declare function proxyForUrl(policy: ProxyPolicy, url: URL): string | undefined;
136
+ //# sourceMappingURL=policy.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@qilin-agent/http-proxy",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "description": "Process-wide outbound HTTP proxy policy for QiLin: resolve it from the launch environment and install it as undici's global dispatcher",
4
+ "version": "3.1.3",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/kkutysllb/QiLin.git",
11
+ "directory": "packages/util/http-proxy"
12
+ },
13
+ "engines": {
14
+ "node": "^22.19.0 || >=24.0.0"
15
+ },
16
+ "type": "module",
17
+ "main": "lib/index.js",
18
+ "types": "lib/types/index.d.ts",
19
+ "exports": {
20
+ ".": {
21
+ "types": "./lib/types/index.d.ts",
22
+ "default": "./lib/index.js"
23
+ },
24
+ "./src/*": "./src/*",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "files": [
28
+ "lib/index.js",
29
+ "lib/types/**/*.d.ts"
30
+ ],
31
+ "license": "MIT",
32
+ "peerDependencies": {
33
+ "@qilin-agent/kylin": "~4.0.3"
34
+ },
35
+ "dependencies": {
36
+ "undici": "^8.10.0"
37
+ },
38
+ "devDependencies": {
39
+ "@qilin-agent/kylin": "~4.0.3"
40
+ }
6
41
  }