From 545e2ad91437ea55f3b991321baf2191962b61c6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:42:26 +0800 Subject: [PATCH 001/131] feat(net): route every outbound request through the configured proxy Node's built-in fetch ignores HTTP_PROXY, so every harness request connected directly regardless of what the user exported. Resolve one policy from the launch environment and install it as undici's global dispatcher, then wire the four surfaces a global dispatcher cannot reach: web_fetch's pinned transport, the OTLP exporter's node:http agent, the E2B SDK's own proxy option, and the environment a child process or worker thread is given. Each outbound call site carries an egress test that drives its real code path through a fake proxy; that measurement is what found the OTLP and E2B gaps. --- ...2026-08-27-outbound-proxy-policy.i18n.yaml | 6 + .../2026-08-27-outbound-proxy-policy.md | 83 +++++ .../2026-08-27-outbound-proxy-policy.zh.md | 83 +++++ AGENTS.md | 1 + apps/cli/package.json | 39 ++- apps/cli/reference/README.i18n.yaml | 4 +- apps/cli/reference/README.md | 2 +- apps/cli/reference/README.zh.md | 2 +- apps/cli/src/profile-boot.ts | 16 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 37 ++- docs/config-catalog.zh.md | 35 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 40 ++- docs/module-graph.zh.md | 40 ++- docs/user/guide/network-proxy.i18n.yaml | 6 + docs/user/guide/network-proxy.md | 74 +++++ docs/user/guide/network-proxy.zh.md | 74 +++++ package.json | 1 + packages/README.i18n.yaml | 4 +- packages/README.md | 1 + packages/README.zh.md | 1 + packages/e2b/e2b/package.json | 10 +- packages/e2b/e2b/src/index.ts | 9 + packages/e2b/e2b/tests/egress.spec.ts | 46 +++ packages/e2b/e2b/tsconfig.json | 7 +- packages/llm/llm-pi-ai/package.json | 7 +- packages/llm/llm-pi-ai/tests/egress.spec.ts | 39 +++ packages/llm/llm-pi-ai/tsconfig.json | 3 + packages/mcp/mcp-client/package.json | 5 +- packages/mcp/mcp-client/tests/egress.spec.ts | 40 +++ packages/mcp/mcp-client/tsconfig.json | 7 +- packages/net/README.i18n.yaml | 6 + packages/net/README.md | 44 +++ packages/net/README.zh.md | 44 +++ packages/net/http-proxy/README.i18n.yaml | 6 + packages/net/http-proxy/README.md | 117 +++++++ packages/net/http-proxy/README.zh.md | 117 +++++++ packages/net/http-proxy/package.json | 48 +++ packages/net/http-proxy/src/index.ts | 86 +++++ packages/net/http-proxy/src/install.ts | 185 +++++++++++ packages/net/http-proxy/src/invariant.ts | 31 ++ packages/net/http-proxy/src/policy.ts | 302 ++++++++++++++++++ packages/net/http-proxy/tests/install.spec.ts | 276 ++++++++++++++++ packages/net/http-proxy/tests/plugin.spec.ts | 106 ++++++ packages/net/http-proxy/tests/policy.spec.ts | 209 ++++++++++++ packages/net/http-proxy/tsconfig.json | 18 ++ .../session-telemetry-otel/package.json | 16 +- .../session-telemetry-otel/src/index.ts | 11 +- .../tests/egress.spec.ts | 71 ++++ .../session-telemetry-otel/tsconfig.json | 3 + packages/subprocess/subprocess/package.json | 10 +- packages/subprocess/subprocess/src/index.ts | 8 +- .../subprocess/tests/egress.spec.ts | 57 ++++ packages/subprocess/subprocess/tsconfig.json | 3 + packages/web/web-fetch-http/package.json | 2 + packages/web/web-fetch-http/src/network.ts | 56 +++- packages/web/web-fetch-http/src/provider.ts | 17 +- .../web/web-fetch-http/tests/proxy.spec.ts | 119 +++++++ packages/web/web-fetch-http/tsconfig.json | 3 + packages/web/web-search-deepseek/package.json | 9 +- .../web-search-deepseek/tests/egress.spec.ts | 39 +++ .../web/web-search-deepseek/tsconfig.json | 3 + packages/web/web-search-exa/package.json | 7 +- .../web/web-search-exa/tests/egress.spec.ts | 39 +++ packages/web/web-search-exa/tsconfig.json | 3 + .../web/web-search-perplexity/package.json | 7 +- .../tests/egress.spec.ts | 39 +++ .../web/web-search-perplexity/tsconfig.json | 3 + .../workflow-worker-thread/package.json | 3 +- .../workflow-worker-thread/src/host.ts | 11 +- .../tests/egress.spec.ts | 51 +++ .../workflow-worker-thread/tsconfig.json | 3 + pnpm-lock.yaml | 55 ++++ python/sdk-runtime/package.json | 51 +-- scripts/run-gates.ts | 2 + scripts/verify-no-bare-dispatcher.spec.ts | 53 +++ scripts/verify-no-bare-dispatcher.ts | 91 ++++++ .../verify-package-readme-model-experience.ts | 1 + scripts/verify-subsystem-pages.ts | 1 + tsconfig.base.json | 2 + tsconfig.host.json | 1 + website/docs.ts | 8 + 83 files changed, 3050 insertions(+), 133 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md create mode 100644 .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md create mode 100644 docs/user/guide/network-proxy.i18n.yaml create mode 100644 docs/user/guide/network-proxy.md create mode 100644 docs/user/guide/network-proxy.zh.md create mode 100644 packages/e2b/e2b/tests/egress.spec.ts create mode 100644 packages/llm/llm-pi-ai/tests/egress.spec.ts create mode 100644 packages/mcp/mcp-client/tests/egress.spec.ts create mode 100644 packages/net/README.i18n.yaml create mode 100644 packages/net/README.md create mode 100644 packages/net/README.zh.md create mode 100644 packages/net/http-proxy/README.i18n.yaml create mode 100644 packages/net/http-proxy/README.md create mode 100644 packages/net/http-proxy/README.zh.md create mode 100644 packages/net/http-proxy/package.json create mode 100644 packages/net/http-proxy/src/index.ts create mode 100644 packages/net/http-proxy/src/install.ts create mode 100644 packages/net/http-proxy/src/invariant.ts create mode 100644 packages/net/http-proxy/src/policy.ts create mode 100644 packages/net/http-proxy/tests/install.spec.ts create mode 100644 packages/net/http-proxy/tests/plugin.spec.ts create mode 100644 packages/net/http-proxy/tests/policy.spec.ts create mode 100644 packages/net/http-proxy/tsconfig.json create mode 100644 packages/session/session-telemetry-otel/tests/egress.spec.ts create mode 100644 packages/subprocess/subprocess/tests/egress.spec.ts create mode 100644 packages/web/web-fetch-http/tests/proxy.spec.ts create mode 100644 packages/web/web-search-deepseek/tests/egress.spec.ts create mode 100644 packages/web/web-search-exa/tests/egress.spec.ts create mode 100644 packages/web/web-search-perplexity/tests/egress.spec.ts create mode 100644 packages/workflow/workflow-worker-thread/tests/egress.spec.ts create mode 100644 scripts/verify-no-bare-dispatcher.spec.ts create mode 100644 scripts/verify-no-bare-dispatcher.ts diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml new file mode 100644 index 0000000000..274440f20f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md +2026-08-27-outbound-proxy-policy.md: 9d0a1545e68652f2f2453e89fbf6321385b6c804 +2026-08-27-outbound-proxy-policy.zh.md: fe4f596c6839b7b1c275abf042b90d08b42643c1 diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md new file mode 100644 index 0000000000..9d0a1545e6 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md @@ -0,0 +1,83 @@ +# Agent Note: One outbound proxy policy, installed before anything can request + +Status: implemented + +English | [中文](2026-08-27-outbound-proxy-policy.zh.md) + +## Problem + +Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`. Every other tool a developer runs — curl, git, npm, pip — honours them, so a user behind a proxy exports the variables once and expects everything to follow. The harness did not: `setGlobalDispatcher`, `ProxyAgent`, and `EnvHttpProxyAgent` appeared zero times across `packages/` and `apps/`, so the model request, every web search, `web_fetch`, MCP over HTTP, the OTLP exporter, and the E2B SDK all connected directly, silently, with no diagnostic anywhere. + +The repository had briefly had an answer and lost it without noticing. PR #971 set `NODE_USE_ENV_PROXY=1` in `bin/dsh`; eleven days later `bbb1b1cc38 cleanup: remove managed source installer` deleted that launcher wholesale, taking the flag with it. What survived was one sentence in `apps/cli/reference/README.md` telling the reader to set a variable that nothing consumed any more. + +That sentence could not have worked anyway, for three measured reasons. `NODE_USE_ENV_PROXY` samples the environment at process start, while `loadLayeredEnv()` merges the `.env` layers afterwards, so a proxy declared in a project or `$DSH_HOME` `.env` is invisible to it. It reaches Node 24.0+ and, on the 22 line, only 22.21+ — while `engines` admits `^22.19.0`, where the variable does not exist and setting it warns about nothing. And it does not reach `web-fetch-http` at all: that provider passes its own `dispatcher` to `fetch`, and an explicit dispatcher overrides the global one whatever the flag says. + +## Decision + +**One policy, resolved once from the launch environment, installed as the global dispatcher.** `packages/net/http-proxy` resolves a `ProxyPolicy` and installs it in `runProfile` immediately after the environment snapshot is provided and before any entry mounts. Node's `fetch` resolves undici's global dispatcher, so every plain `fetch()` and every SDK that reaches `globalThis.fetch` is covered without touching its code — nine call sites at the time of writing, and every future one for free. `loadLayeredEnv` has exactly one caller and `apps/web` ships no bin, so this single site covers every profile including `sdk-minimal`, which does not layer over `base`. + +Resolution reads the launcher's snapshot rather than `process.env`, which is what makes a proxy in a `.env` layer work — the capability the environment-variable approach cannot have. + +**A new `packages/net/` group.** The package must depend on `undici` (Node exposes no `node:undici`), so it cannot join the zero-dependency `util/` group; and `boot`, `web`, `subprocess`, and `workflow` all consume it, so joining any one of them would invert three dependencies. It is deliberately not a capability seam: transport policy has one implementation and one answer per process, so there is nothing to swap. + +**The installed agent reads back what was resolved, not the raw environment.** `installGlobalProxy` publishes the policy into the proxy environment variables and then constructs `EnvHttpProxyAgent` with no options. Passing the fields explicitly instead would let undici fall back to reading the environment for any field left `undefined` — including a SOCKS or malformed value this package had already rejected, which `new ProxyAgent` would then throw on during boot. Publishing also normalizes what children inherit: the `ALL_PROXY` fallback lands as a concrete `HTTP_PROXY`, and the bypass list arrives with loopback merged. + +This keeps `proxyForUrl()` and the dispatcher answering from one set of values. They must agree: if they disagreed about a URL, `web-fetch-http` would pin a connection the dispatcher meant to tunnel. + +**Resolution supplies what neither Node nor undici does.** `ALL_PROXY` backs both schemes; a blank value counts as unset, because undici's `??` chain lets an empty lowercase name shadow a populated uppercase one; loopback is always bypassed, since the Web UI, the Connection transport, and every local test server would otherwise route through the proxy and loop. The bypass list carries `::1` *and* `[::1]`: undici's own matcher reads a bare `::1` as host `:` port `1` and never exempts it. + +**Rejection is loud or quiet by where the value came from.** A SOCKS URL, an unparseable string, or an unsupported scheme *from the environment* is reported on stderr and skipped — that variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The same value through the plugin's `Config` throws at load, because that is the harness's own configuration surface, where `AGENTS.md` requires misconfiguration to fail loud. + +**Through a proxy, `web_fetch` stops resolving and pinning.** The provider validates a public address set and pins the connection to it. Through a proxy there is nothing to pin — the proxy performs the origin's DNS — and a pinned direct connection would bypass the proxy entirely. So a proxied hop skips resolution, and configuring a proxy is a statement that the proxy is trusted with destination selection. A hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, takes the resolved-and-pinned path unchanged. Kimi Code and Claude Code reached this same conclusion independently. + +The URL-level policy is untouched: `http(s)` only, no embedded credentials, the length cap, and the cross-origin redirect refusal all still apply on every hop. + +**A separate Node execution context gets the policy through its environment.** `childProxyEnv()` returns the resolved names plus `NODE_USE_ENV_PROXY=1`, merged into `scrubbedParentEnv()` — one function every spawner already shares — and into `workerSpawnEnv()`. A worker thread has its own `globalThis` and does not inherit the global dispatcher, and its environment is built explicitly rather than inherited, so it needs the names as well as the flag. Measured: the flag does take effect for a `Worker` given an explicit `env`. + +This accepts a documented seam. Such a context matches bypass entries by Node's rules, which differ from this package's in separators and IPv4-range support, and the flag exists only on Node 22.21+ and 24+. + +**Two SDKs do not reach `globalThis.fetch`, and reading their code said otherwise.** The audit first classified the OTLP exporter and the E2B SDK as covered, on a grep that found `globalThis.fetch` in `@opentelemetry/otlp-exporter-base`. That match is the *browser* transport; on Node the delegate selects `http-exporter-transport`, which posts through `node:http` — where a global dispatcher does not reach. E2B is a second shape again: it builds its own undici `Agent`/`ProxyAgent` and takes a `proxy` URL that it never reads from the environment. Both were measured direct and both are now wired — the exporter through `createNodeHttpAgent` as its `httpAgentOptions` factory, E2B through `proxyUrlFor` into `Sandbox.create`. + +**Every call site carries an egress test, because reading the code was not enough.** `egress.spec.ts` in each owning package drives that site's real code path at an unresolvable `.invalid` host through a fake proxy and asserts the proxy saw the request. Nine of them cover the search backends, pi-ai discovery, MCP over HTTP, telemetry, E2B, a spawned child Node, and a worker thread. The gate below cannot see inside a dependency; these can, and they are what turns "an SDK changed its transport" from a silent regression into a failing test. + +**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` rejects `new Agent(...)` and an explicit `dispatcher:` outside the owning package. `createDispatcher(url, options)` is the sanctioned replacement, and a line that must genuinely ignore the proxy says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it. + +## Alternatives considered + +**Document `NODE_USE_ENV_PROXY=1` and stop.** Rejected on three measurements, above: invisible to `.env` layers, absent on the lowest supported Node, and bypassed by `web-fetch-http` regardless. It is also what the repository already claimed to do. + +**Thread a policy value to every call site.** DeepSeek-Reasonix does this across 98 sites, buying a per-provider opt-out. Rejected: that opt-out exists for a need this harness does not have, and nine sites changed by hand means the tenth is forgotten — Pi's changelog records OAuth and Bedrock as two separate after-the-fact fixes of exactly that kind. The isolation argument for it is real, and is answered instead by handling worker threads explicitly and by proving disposal restores the previous dispatcher. + +**`http.setGlobalProxyFromEnv()`.** Node's own programmatic switch covers `fetch` and `node:http` together and returns a restore function — the shape `ctx.effect()` wants. Unusable: `added: v24.14.0`, with nothing on the 22 line. Worth revisiting if `engines` ever rises past it. + +**Patch `globalThis.fetch` via `undici.install()`.** Pi does, to keep fetch and the dispatcher on one undici when a newer Node's bundled fetch mishandles compressed responses through a userland dispatcher. Rejected as speculative here: this repository's `engines` ceiling has not reached that runtime. + +**Make this a capability seam.** Rejected. Service Definition / Provider / Consumer is for swappable backends; this has one implementation and one answer per process. If operating-system proxy or PAC support ever lands, `resolveProxyPolicy` is the extension point. + +**Read the operating system's proxy settings.** Rejected for this change. Only Codex and Reasonix among six surveyed products do it, and Codex keeps it behind a default-off flag. Measured on the author's machine, it would have found nothing: the proxy application had written the setting to the Wi-Fi service while the primary interface was a USB ethernet adapter with no proxy, so `scutil --proxy` reported none while the exported variables worked. It also needs its own bypass matcher, because an operating system list carries CIDR entries that neither undici nor Node matches. + +**Give the `code-runtime` worker the proxy too.** Rejected. Model-authored programs run there with no ambient environment at all — a stronger containment than the scrubbed environment spawned commands get — and a proxy URL may carry credentials. Handing model code a credentialed URL to reach the network is the wrong trade; the exclusion is recorded in that package's limitations. + +## Consequences + +A user who exports `HTTPS_PROXY`, or writes it into a `.env` layer, is proxied everywhere the harness makes a request, with no flag and no configuration. Compositions that want the policy in `cordis.yml` mount the plugin; it is in no shipped bundle, so the default path installs exactly once. + +Because the operating system's settings are not read, the user-facing documentation is now load-bearing rather than supplementary: a user who only toggled "system proxy" in a proxy application gets nothing and no diagnostic. `docs/user/guide/network-proxy.md` therefore states which variables to export and why a browser is proxied when a terminal is not — the three-mechanism confusion is the single most common report, and it is not specific to this harness. + +`web_fetch`'s safety story now has two shapes, and its README says so: direct hops keep address validation and pinning, proxied hops delegate destination selection to a proxy the operator configured. This is the one outward-facing security promise the change alters. + +Reaching Node's built-in `fetch` from a userland undici depends on both writing the legacy `Symbol.for('undici.globalDispatcher.1')` slot. That is an implicit cross-version coupling rather than a contract — corepack#834 records it breaking — so `tests/install.spec.ts` drives a real request through a loopback proxy. A version bump that breaks the coupling fails there instead of in the field. + +The suite is hermetic against the developer's own environment: `plugin.spec.ts` saves and clears all eight proxy names in both casings. It has to. An exported lowercase `all_proxy` decided a test's outcome during development, because resolution reads lowercase first. + +## Testing + +`packages/net/http-proxy` holds 64 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and `mode: 'off'`; bypass matching covers suffixes, ports, both IPv6 spellings, and the CIDR entry that deliberately does not match. Installation drives a real loopback proxy and asserts the absolute-form request arrives, that a bypassed target does not, and that disposal restores the dispatcher, the policy, and the environment. + +`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts the decision that matters most: under a proxy the public-address resolver is never called, while a bypassed hop still calls it exactly once, and the cross-origin redirect refusal survives on the proxied path. + +`verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `createDispatcher`, accepts an annotated exemption, and passes on the current tree. + +The egress suite also carries the negative case for telemetry: without the agent this change supplies, the exporter reaches no proxy at all. That assertion is what keeps the fix from being quietly reverted by an SDK upgrade that restores the default agent. + +No recorded-session snapshot changes: nothing here alters a model-visible input or product-user-visible transcript output. diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md new file mode 100644 index 0000000000..fe4f596c68 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md @@ -0,0 +1,83 @@ +# Agent Note: 一份出站代理策略,在任何请求发生之前装好 + +Status: implemented + +[English](2026-08-27-outbound-proxy-policy.md) | 中文 + +## Problem + +Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运行的其他工具——curl、git、npm、pip——都遵循它们,所以代理后面的用户导出一次变量就期待一切随之生效。Harness 并没有:`setGlobalDispatcher`、`ProxyAgent` 与 `EnvHttpProxyAgent` 在 `packages/` 与 `apps/` 中出现次数为零,因此模型请求、每次 web 搜索、`web_fetch`、走 HTTP 的 MCP、OTLP 导出器与 E2B SDK 全部直连,且是静默的,任何地方都没有诊断。 + +仓库曾短暂拥有过答案,又在无人察觉时弄丢了。PR #971 在 `bin/dsh` 里设置了 `NODE_USE_ENV_PROXY=1`;十一天后 `bbb1b1cc38 cleanup: remove managed source installer` 整体删除了那个启动器,把该标志一并带走。留下的只有 `apps/cli/reference/README.md` 里的一句话,让读者去设置一个已经无人消费的变量。 + +即便照做,那句话也不可能生效,原因有三条且都经过实测。`NODE_USE_ENV_PROXY` 在进程启动时对环境取快照,而 `loadLayeredEnv()` 是在之后才合并 `.env` 层,因此写在项目或 `$DSH_HOME` `.env` 中的代理对它不可见。它只覆盖 Node 24.0+,在 22 线上只覆盖 22.21+——而 `engines` 允许 `^22.19.0`,那里根本没有这个变量,设置了也不会有任何警告。它也完全触及不到 `web-fetch-http`:该提供方向 `fetch` 传入自己的 `dispatcher`,而显式 dispatcher 无论标志如何都会覆盖全局的那个。 + +## Decision + +**一份策略,从启动环境解析一次,装为全局 dispatcher。** `packages/net/http-proxy` 解析出 `ProxyPolicy`,并在 `runProfile` 中于环境快照提供之后、任何 entry 挂载之前完成安装。Node 的 `fetch` 解析的正是 undici 的全局 dispatcher,因此每一处普通 `fetch()` 以及每一个最终落到 `globalThis.fetch` 的 SDK 都无需改动即被覆盖——撰写时是九个调用点,未来新增的也自动覆盖。`loadLayeredEnv` 只有一个调用方,且 `apps/web` 不提供 bin,因此这一处即覆盖全部 profile,包括不叠加 `base` 的 `sdk-minimal`。 + +解析读取的是启动器的快照而非 `process.env`,这正是让 `.env` 层中的代理生效的原因——也是环境变量方案不可能具备的能力。 + +**新增 `packages/net/` 分组。** 本包必须依赖 `undici`(Node 不暴露 `node:undici`),因此无法加入零依赖的 `util/` 组;而 `boot`、`web`、`subprocess` 与 `workflow` 都消费它,放进其中任何一组都会让另外三条依赖反向。它刻意不是能力接缝:传输策略每个进程只有一种实现、一个答案,没有可替换的对象。 + +**已安装的 agent 读回的是解析结果,而非原始环境。** `installGlobalProxy` 把策略发布到代理环境变量中,再以无选项方式构造 `EnvHttpProxyAgent`。若改为显式传字段,undici 会对任何留空的字段回退去读环境——包括本包已经拒绝的 SOCKS 或畸形值,而 `new ProxyAgent` 会因此在启动期抛出。发布同时也规范化了子进程继承到的内容:`ALL_PROXY` 兜底落为具体的 `HTTP_PROXY`,绕过列表也已并入 loopback。 + +这样 `proxyForUrl()` 与 dispatcher 就从同一组值给出答案。两者必须一致:一旦对某个 URL 产生分歧,`web-fetch-http` 就会把 dispatcher 本打算隧道转发的连接固定到某个地址上。 + +**解析补上 Node 与 undici 都不提供的部分。** `ALL_PROXY` 为两种协议兜底;空值视为未设置,因为 undici 的 `??` 链会让空的小写名遮住有值的大写名;loopback 始终绕过,否则 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。绕过列表同时携带 `::1` **与** `[::1]`:undici 自带的匹配器会把裸写的 `::1` 读成主机 `:` 端口 `1`,从而永不豁免它。 + +**拒绝是响还是静,取决于值从哪来。** 来自**环境**的 SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。同样的值若经由插件的 `Config` 传入,则在加载期抛出,因为那是 Harness 自己的配置面,`AGENTS.md` 要求配置错误必须响。 + +**经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。 + +URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。 + +**独立的 Node 执行上下文通过环境获得策略。** `childProxyEnv()` 返回解析出的变量名加上 `NODE_USE_ENV_PROXY=1`,并入 `scrubbedParentEnv()`(每个 spawner 本就共享的一个函数)与 `workerSpawnEnv()`。worker 线程拥有独立的 `globalThis`,不继承全局 dispatcher,而且它的环境是显式构造而非继承的,因此除标志外还需要那些变量名。已实测:对给定显式 `env` 的 `Worker`,该标志确实生效。 + +这接受了一处已记录的接缝。此类上下文按 Node 自己的规则匹配绕过条目,其分隔符与 IPv4 区间支持与本包不同,且该标志仅存在于 Node 22.21+ 与 24+。 + +**有两个 SDK 并不落到 `globalThis.fetch`,而读代码给出的答案是相反的。** 审计最初把 OTLP 导出器与 E2B SDK 判为已覆盖,依据是在 `@opentelemetry/otlp-exporter-base` 里 grep 到了 `globalThis.fetch`。那处命中属于**浏览器**传输;在 Node 上 delegate 选择的是 `http-exporter-transport`,它通过 `node:http` 投递——那里全局 dispatcher 触及不到。E2B 又是另一种形态:它自建 undici `Agent`/`ProxyAgent`,并接受一个自己从不从环境读取的 `proxy` URL。两者都实测为直连,现均已接通——导出器通过把 `createNodeHttpAgent` 作为其 `httpAgentOptions` 工厂,E2B 通过把 `proxyUrlFor` 传入 `Sandbox.create`。 + +**每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、遥测、E2B、派生的子 Node 与 worker 线程。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。 + +**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 在所属包之外拒绝 `new Agent(...)` 与显式 `dispatcher:`。`createDispatcher(url, options)` 是受支持的替代;确实必须忽略代理的行用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。 + +## Alternatives considered + +**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 基于上文三条实测被否决:对 `.env` 层不可见、在最低支持的 Node 上不存在、且无论如何被 `web-fetch-http` 绕过。而这恰恰是仓库此前声称的做法。 + +**把策略值传递到每一个调用点。** DeepSeek-Reasonix 在 98 处这样做,换来每提供方的 opt-out。被否决:该能力服务于本 Harness 并不具备的需求,而手工改九处意味着第十处会被遗忘——Pi 的变更日志正记录了 OAuth 与 Bedrock 两次事后补漏。它关于隔离性的论点确实成立,本方案改为显式处理 worker 线程、并以「dispose 后还原前一个 dispatcher」的断言来回应。 + +**`http.setGlobalProxyFromEnv()`。** Node 自带的程序化开关同时覆盖 `fetch` 与 `node:http`,并返回还原函数——正是 `ctx.effect()` 想要的形态。不可用:`added: v24.14.0`,22 线上完全没有。若 `engines` 日后升过该版本,值得回头替换。 + +**用 `undici.install()` patch `globalThis.fetch`。** Pi 这样做,是为了让 fetch 与 dispatcher 处于同一个 undici——较新 Node 的内置 fetch 经 userland dispatcher 处理压缩响应时会出错。此处被否决为投机性复杂度:本仓库 `engines` 的上限尚未触及该运行时。 + +**做成能力接缝。** 被否决。Service Definition/Provider/Consumer 用于可替换后端;这里每个进程只有一种实现、一个答案。若日后要支持操作系统代理或 PAC,`resolveProxyPolicy` 就是扩展点。 + +**读取操作系统的代理设置。** 本次变更中被否决。所调研的六个产品中只有 Codex 与 Reasonix 这样做,且 Codex 把它放在默认关闭的开关之后。在作者机器上实测,它什么也读不到:代理软件把设置写在了 Wi-Fi 服务上,而主接口是一块没有代理的 USB 以太网卡,因此 `scutil --proxy` 报告无代理,而导出的环境变量却工作正常。它还需要自带的绕过匹配器,因为操作系统的列表含有 undici 与 Node 都不匹配的 CIDR 条目。 + +**也把代理给 `code-runtime` worker。** 被否决。模型编写的程序在那里运行时完全没有环境变量——这比派生命令得到的 scrubbed 环境更严——而代理 URL 可能携带凭据。把带凭据的 URL 交给模型代码去访问网络是错误的取舍;该排除已记入那个包的限制清单。 + +## Consequences + +导出了 `HTTPS_PROXY`、或把它写进 `.env` 层的用户,在 Harness 发起请求的每一处都会走代理,无需任何标志与配置。希望把策略写进 `cordis.yml` 的组合可挂载该插件;它不在任何随附组合包中,因此默认路径只安装一次。 + +由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `docs/user/guide/network-proxy.md` 说明了要导出哪些变量,以及为什么浏览器走代理而终端不走——这个「三套机制」的困惑是最常见的报障,且并非本 Harness 特有。 + +`web_fetch` 的安全叙述现在有两种形态,其 README 已如实说明:直连的一跳保留地址校验与固定,代理转发的一跳把目的地选择交给运维方配置的代理。这是本次变更唯一改动的对外安全承诺。 + +userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合而非约定——corepack#834 记录了它失效的实例——因此 `tests/install.spec.ts` 会驱动一次真实请求穿过 loopback 代理。破坏该耦合的版本升级会在那里失败,而不是流到线上。 + +测试套件对开发者自身的环境免疫:`plugin.spec.ts` 会保存并清除全部八个代理变量名的两种大小写形式。这是必需的。开发过程中,一个已导出的小写 `all_proxy` 曾决定了某个测试的结果,因为解析优先读取小写。 + +## Testing + +`packages/net/http-proxy` 有 64 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及 `mode: 'off'`;绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。 + +`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言了最关键的那个决定:经由代理时公网地址解析器完全不被调用,而被绕过的一跳仍恰好调用一次,且跨域重定向拒绝在代理路径上依然成立。 + +`verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `createDispatcher`、接受带注释的豁免,并在当前代码树上通过。 + +出网测试还为遥测保留了负向用例:不带本次提供的 agent 时,导出器完全触及不到代理。该断言可以防止某次 SDK 升级恢复默认 agent 后把修复悄悄回退掉。 + +无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。 diff --git a/AGENTS.md b/AGENTS.md index 0403610833..c8f106adb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ packages/ @deepseek-ai/dsh- workspaces at packages/// lsp/ language-server capability skill/ skill provider registry + local impl + catalog/loader tool web/ web capability: Service Definition + search/fetch providers + tool Consumer + net/ outbound transport policy: the HTTP proxy every request inherits compaction/ compaction capability + basic provider context/ request-context plugins subagent/ subagent capability: Service Definition + providers + delegation Consumers diff --git a/apps/cli/package.json b/apps/cli/package.json index 8f82b8fdf7..04014e164a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -19,68 +19,75 @@ ], "dsh": { "configTrees": [ - { "mount": "config/agent-presets", "path": "../../packages/preset/agent-presets/presets", "scanRoster": true } + { + "mount": "config/agent-presets", + "path": "../../packages/preset/agent-presets/presets", + "scanRoster": true + } ] }, "license": "MIT", "dependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-hmr": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-acp-app": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", - "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^", "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", + "@deepseek-ai/dsh-cordis-client-runner": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", - "@deepseek-ai/dsh-cmdline": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", - "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-hooks-claude-code": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-persona": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", + "@deepseek-ai/dsh-schedule": "workspace:^", "@deepseek-ai/dsh-sdk-app": "workspace:^", "@deepseek-ai/dsh-sdk-minimal": "workspace:^", - "@deepseek-ai/dsh-time-context": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", + "@deepseek-ai/dsh-time-context": "workspace:^", "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", - "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-pwsh": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", - "@deepseek-ai/dsh-schedule": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", @@ -88,10 +95,8 @@ "@deepseek-ai/dsh-webhook": "workspace:^", "@deepseek-ai/dsh-webhook-github": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", - "@deepseek-ai/cordis": "workspace:^", "js-yaml": "^4.2.0", "node-addon-require-builtin": "^0.1.4" }, diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0a1463fc49..602546eebc 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: f5cbe1659e5181cdaa6eaefcdb9bd8c8fe289e6d -README.zh.md: cf040f085241f0af58eb3db485bcedd4316726be +README.md: 9e88d527ebaed9a20b64ea77016fa7af4ca4c216 +README.zh.md: 2d4f330aa46c94e8ebc2798dd678242ee2acb528 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index f5cbe1659e..9e88d527eb 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -98,4 +98,4 @@ Install external plugin bundles through `dsh plugin --profile add ## Source execution -From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment; set `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. +From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh `. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index cf040f0852..2d4f330aa4 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -98,4 +98,4 @@ dsh web --help ## 源码执行 -请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,请设置 `NODE_USE_ENV_PROXY=1`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 +请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh `。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index ad5c362d3d..dbac4eeed0 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -30,6 +30,7 @@ import { type Profile, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import { installGlobalProxy, resolveProxyPolicy } from '@deepseek-ai/dsh-http-proxy' import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' @@ -207,10 +208,23 @@ function suppressShutdownError(ctx: Context, signal: AbortSignal, error: unknown * @returns the settled root context and the shutdown controller. */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { + // Before the first plugin mounts and before anything can issue a request: Node's fetch ignores the + // proxy environment on its own, so every profile would otherwise connect directly. Resolving from + // the launcher's snapshot — not `process.env` — is what lets a proxy declared in a `.env` layer + // work, which the NODE_USE_ENV_PROXY flag cannot do because Node samples the environment at start. + const { policy: proxyPolicy, diagnostics } = resolveProxyPolicy(options.environment) + // A proxy variable may have been exported for other tools, so a value this harness cannot use is + // reported and skipped rather than being allowed to stop the agent from starting. + for (const diagnostic of diagnostics) process.stderr.write(`${NAME}: ${diagnostic.message}\n`) + const disposeProxy = await installGlobalProxy(proxyPolicy) + const composed = await composeProfile(options.profile, options.patchFiles) const app: { current?: Context } = {} const appReady = createAppReady() - const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) + const shutdown = createProcessShutdown(async () => { + await app.current?.fiber.dispose() + await disposeProxy() + }) const signalShutdown = new AbortController() const interrupt = (code: number): void => { signalShutdown.abort() diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 9ccc5d22db..867063c3a6 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 9e2b671ee054af797e9a919920fdd799e8c50e61 -config-catalog.zh.md: df386630eeddefaccd9d1630da95a7116492173c +config-catalog.md: 6911366cd70f7fcf0bf0c0ed62d79e0bcd55e680 +config-catalog.zh.md: 69a069624732ca5111c14a3939fa9c43e26d961c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9e2b671ee0..6911366cd7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -604,7 +604,7 @@ export interface Config { } ``` -Source: [`packages/e2b/e2b/src/index.ts:43`](../packages/e2b/e2b/src/index.ts) +Source: [`packages/e2b/e2b/src/index.ts:44`](../packages/e2b/e2b/src/index.ts) @@ -924,6 +924,39 @@ export interface Config { Source: [`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts) + + +## `@deepseek-ai/dsh-http-proxy` + +```ts config-catalog +/** Composition-declared proxy settings; every field is optional and the environment outranks them. */ +export interface Config extends ProxyConfig {} + +/** + * Proxy settings a composition may declare in `cordis.yml`. Real environment variables win over every + * field here except `mode`, which governs whether the environment is consulted at all. + */ +export interface ProxyConfig { + /** + * `env` (default) resolves from the environment and lets the fields below fill the gaps; `custom` + * does the same but is the honest label for a composition that supplies its own proxy; `off` + * ignores every source and keeps the harness's own requests direct. + * + * `off` governs requests this process issues. It does not strip proxy variables from the + * environment child tools inherit, because those belong to the user, not to the harness. + */ + mode?: 'env' | 'custom' | 'off' + /** Proxy for `http:` origins when the environment supplies none. */ + httpProxy?: string + /** Proxy for `https:` origins when the environment supplies none. */ + httpsProxy?: string + /** Bypass list when the environment supplies none. {@link LOOPBACK_NO_PROXY} is merged in regardless. */ + noProxy?: string +} +``` + +Source: [`packages/net/http-proxy/src/index.ts:49`](../packages/net/http-proxy/src/index.ts) + ## `@deepseek-ai/dsh-invariants` @@ -2047,7 +2080,7 @@ export enum SessionTelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:92`](../packages/session/session-telemetry-otel/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index df386630ee..69a0696247 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -606,7 +606,7 @@ export interface Config { } ``` -来源:[`packages/e2b/e2b/src/index.ts:43`](../packages/e2b/e2b/src/index.ts) +来源:[`packages/e2b/e2b/src/index.ts:44`](../packages/e2b/e2b/src/index.ts) @@ -926,6 +926,39 @@ export interface Config { 来源:[`packages/host/webserver/src/index.ts:59`](../packages/host/webserver/src/index.ts) + + +## `@deepseek-ai/dsh-http-proxy` + +```ts config-catalog +/** Composition-declared proxy settings; every field is optional and the environment outranks them. */ +export interface Config extends ProxyConfig {} + +/** + * Proxy settings a composition may declare in `cordis.yml`. Real environment variables win over every + * field here except `mode`, which governs whether the environment is consulted at all. + */ +export interface ProxyConfig { + /** + * `env` (default) resolves from the environment and lets the fields below fill the gaps; `custom` + * does the same but is the honest label for a composition that supplies its own proxy; `off` + * ignores every source and keeps the harness's own requests direct. + * + * `off` governs requests this process issues. It does not strip proxy variables from the + * environment child tools inherit, because those belong to the user, not to the harness. + */ + mode?: 'env' | 'custom' | 'off' + /** Proxy for `http:` origins when the environment supplies none. */ + httpProxy?: string + /** Proxy for `https:` origins when the environment supplies none. */ + httpsProxy?: string + /** Bypass list when the environment supplies none. {@link LOOPBACK_NO_PROXY} is merged in regardless. */ + noProxy?: string +} +``` + +来源:[`packages/net/http-proxy/src/index.ts:47`](../packages/net/http-proxy/src/index.ts) + ## `@deepseek-ai/dsh-invariants` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 7112bfb506..05b8931616 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 7c4ccbf6841070d37116641c1404a70cbb598769 -module-graph.zh.md: 4581a5bdda1e75a9678a731ab109d3b3c71b7434 +module-graph.md: 4aa7db428e91719bdd6aaee385c41af903922726 +module-graph.zh.md: 7d390651a8887946b9354b80964e185b5c2c8223 diff --git a/docs/module-graph.md b/docs/module-graph.md index 7c4ccbf684..4aa7db428e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -262,6 +262,9 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_net["packages/net"] + pkg_http_proxy["http-proxy"] + end subgraph group_preset["packages/preset"] pkg_agent_presets["agent-presets"] pkg_persona["persona"] @@ -382,7 +385,6 @@ flowchart TD pkg_client_web --> pkg_invariants pkg_code_runtime --> pkg_invariants pkg_code_runtime_python --> pkg_invariants - pkg_e2b --> pkg_invariants pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants @@ -392,7 +394,6 @@ flowchart TD pkg_host_webserver --> pkg_invariants pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants - pkg_subprocess --> pkg_invariants pkg_win32_process --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_typert_generator --> pkg_invariants @@ -404,25 +405,20 @@ flowchart TD pkg_client_modules --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants - pkg_subprocess_e2b --> pkg_e2b - pkg_subprocess_e2b --> pkg_invariants - pkg_subprocess_e2b --> pkg_subprocess - pkg_subprocess_e2b --> pkg_timeout pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants + pkg_http_proxy --> pkg_invariants + pkg_http_proxy --> pkg_launch_environment pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage - pkg_subprocess_local --> pkg_invariants - pkg_subprocess_local --> pkg_subprocess - pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm --> pkg_attachment @@ -441,9 +437,13 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment + pkg_e2b --> pkg_http_proxy + pkg_e2b --> pkg_invariants pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants + pkg_subprocess --> pkg_http_proxy + pkg_subprocess --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -460,11 +460,19 @@ flowchart TD pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm + pkg_subprocess_e2b --> pkg_e2b + pkg_subprocess_e2b --> pkg_invariants + pkg_subprocess_e2b --> pkg_subprocess + pkg_subprocess_e2b --> pkg_timeout pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm + pkg_subprocess_local --> pkg_invariants + pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill + pkg_web_fetch_http --> pkg_http_proxy pkg_web_fetch_http --> pkg_invariants pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web @@ -944,6 +952,7 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_tools pkg_session_telemetry_otel --> pkg_anonymous_user_id pkg_session_telemetry_otel --> pkg_command_feedback + pkg_session_telemetry_otel --> pkg_http_proxy pkg_session_telemetry_otel --> pkg_invariants pkg_session_telemetry_otel --> pkg_llm pkg_session_telemetry_otel --> pkg_session @@ -1763,7 +1772,6 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1773,7 +1781,6 @@ flowchart TD | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1782,27 +1789,30 @@ flowchart TD | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`http-proxy`](../packages/net/http-proxy) | `net` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | +| [`e2b`](../packages/e2b/e2b) | `e2b` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1893,7 +1903,7 @@ flowchart TD | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 4581a5bdda..7d390651a8 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -264,6 +264,9 @@ flowchart TD subgraph group_mcp["packages/mcp"] pkg_mcp_client["mcp-client"] end + subgraph group_net["packages/net"] + pkg_http_proxy["http-proxy"] + end subgraph group_preset["packages/preset"] pkg_agent_presets["agent-presets"] pkg_persona["persona"] @@ -384,7 +387,6 @@ flowchart TD pkg_client_web --> pkg_invariants pkg_code_runtime --> pkg_invariants pkg_code_runtime_python --> pkg_invariants - pkg_e2b --> pkg_invariants pkg_experimental_agent_team_profile --> pkg_invariants pkg_experimental_agent_team_web_profile --> pkg_invariants pkg_experimental_webworker_packer --> pkg_invariants @@ -394,7 +396,6 @@ flowchart TD pkg_host_webserver --> pkg_invariants pkg_sandbox_windows_acl --> pkg_invariants pkg_storage --> pkg_invariants - pkg_subprocess --> pkg_invariants pkg_win32_process --> pkg_invariants pkg_llm_mock_server --> pkg_invariants pkg_typert_generator --> pkg_invariants @@ -406,25 +407,20 @@ flowchart TD pkg_client_modules --> pkg_invariants pkg_credentials --> pkg_brand pkg_credentials --> pkg_invariants - pkg_subprocess_e2b --> pkg_e2b - pkg_subprocess_e2b --> pkg_invariants - pkg_subprocess_e2b --> pkg_subprocess - pkg_subprocess_e2b --> pkg_timeout pkg_host_plugin_inventory --> pkg_brand pkg_host_plugin_inventory --> pkg_invariants pkg_host_plugin_inventory --> pkg_typert_protocol pkg_anonymous_user_id --> pkg_brand pkg_anonymous_user_id --> pkg_home_paths pkg_anonymous_user_id --> pkg_invariants + pkg_http_proxy --> pkg_invariants + pkg_http_proxy --> pkg_launch_environment pkg_storage_domain --> pkg_invariants pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_invariants pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_invariants pkg_storage_sqlite --> pkg_storage - pkg_subprocess_local --> pkg_invariants - pkg_subprocess_local --> pkg_subprocess - pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry pkg_llm --> pkg_attachment @@ -443,9 +439,13 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_launch_environment + pkg_e2b --> pkg_http_proxy + pkg_e2b --> pkg_invariants pkg_experimental_inspector --> pkg_client_modules pkg_experimental_inspector --> pkg_host_webserver pkg_experimental_inspector --> pkg_invariants + pkg_subprocess --> pkg_http_proxy + pkg_subprocess --> pkg_invariants pkg_session --> pkg_brand pkg_session --> pkg_invariants pkg_session --> pkg_llm @@ -462,11 +462,19 @@ flowchart TD pkg_authorization --> pkg_credentials pkg_authorization --> pkg_invariants pkg_authorization --> pkg_llm + pkg_subprocess_e2b --> pkg_e2b + pkg_subprocess_e2b --> pkg_invariants + pkg_subprocess_e2b --> pkg_subprocess + pkg_subprocess_e2b --> pkg_timeout pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm + pkg_subprocess_local --> pkg_invariants + pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_skill_badge --> pkg_invariants pkg_skill_badge --> pkg_skill + pkg_web_fetch_http --> pkg_http_proxy pkg_web_fetch_http --> pkg_invariants pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web @@ -946,6 +954,7 @@ flowchart TD pkg_session_checkpoint_policy --> pkg_tools pkg_session_telemetry_otel --> pkg_anonymous_user_id pkg_session_telemetry_otel --> pkg_command_feedback + pkg_session_telemetry_otel --> pkg_http_proxy pkg_session_telemetry_otel --> pkg_invariants pkg_session_telemetry_otel --> pkg_llm pkg_session_telemetry_otel --> pkg_session @@ -1765,7 +1774,6 @@ flowchart TD | [`client-web`](../packages/client/web) | `client` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`code-runtime-python`](../packages/code-runtime/code-runtime-python) | `code-runtime` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`e2b`](../packages/e2b/e2b) | `e2b` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1775,7 +1783,6 @@ flowchart TD | [`host-webserver`](../packages/host/webserver) | `host` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`storage`](../packages/storage/storage) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1784,27 +1791,30 @@ flowchart TD | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`http-proxy`](../packages/net/http-proxy) | `net` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-registry`](../packages/typert/registry) | | [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`typert-protocol`](../packages/typert/protocol) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment) | +| [`e2b`](../packages/e2b/e2b) | `e2b` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`typert-protocol`](../packages/typert/protocol) | | [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`web`](../packages/web/web) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1895,7 +1905,7 @@ flowchart TD | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | | [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy) | `session` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | +| [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | | [`session-title-all-prompts-llm`](../packages/session/session-title-all-prompts-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`session-title-first-prompt-llm`](../packages/session/session-title-first-prompt-llm) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`session-title-llm`](../packages/session/session-title-llm) | | [`shell-env`](../packages/shell/shell-env) | `shell` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml new file mode 100644 index 0000000000..7703dfeb00 --- /dev/null +++ b/docs/user/guide/network-proxy.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/user/guide/network-proxy.md +network-proxy.md: 33f84766967ccac501629e930eaaef1b1c24d8b9 +network-proxy.zh.md: 82aa63bb042d873c1fb16090a2a289dac033c4aa diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md new file mode 100644 index 0000000000..33f8476696 --- /dev/null +++ b/docs/user/guide/network-proxy.md @@ -0,0 +1,74 @@ +# Run DSH behind a network proxy + +English | [中文](network-proxy.zh.md) + +DSH routes every outbound request — model calls, web search, page fetches, MCP servers over HTTP, and telemetry — through the proxy named by the standard proxy environment variables. It reads them at launch; nothing else needs configuring. + +## Export the variables + +```sh +export HTTPS_PROXY=http://127.0.0.1:7890 +export HTTP_PROXY=http://127.0.0.1:7890 +``` + +Put both lines in your shell profile so every `dsh` invocation inherits them. DSH also reads a `.env` file in the launch directory and in `$DSH_HOME`, so a proxy that should apply to one project can live there instead; a real environment variable always wins over a file. + +A proxy that needs credentials takes them in the URL: `http://user:password@proxy.example:8080`. DSH never prints the password back — a proxy it reports in a diagnostic shows the username and masks the rest. + +## Why your browser is proxied but your terminal is not + +This is the most common surprise, and it is not specific to DSH. There is no single "system proxy" that all software obeys — there are three unrelated mechanisms: + +| Mechanism | Who follows it | +|---|---| +| The operating system's proxy settings | Safari, most native macOS apps, Chrome and Edge | +| The `HTTP_PROXY` / `HTTPS_PROXY` environment variables | `curl`, `git`, `npm`, `pip`, and DSH | +| TUN mode (a virtual network interface) | Everything, transparently | + +The "system proxy" switch in a proxy application such as Clash writes only the first one. Browsers pick it up; command-line tools never see it. That is why exporting the variables is a separate step, and why turning on TUN mode makes both work without any variables at all. + +DSH does not read the operating system's proxy settings. Export the variables, or use TUN mode. + +## Choose what stays direct + +`NO_PROXY` lists hosts to reach directly: + +```sh +export NO_PROXY=internal.example.com,.corp.example.com,registry.local +``` + +An entry matches an exact host, a `.suffix` or `*.suffix` domain, an optional `:port`, or `*` for everything. + +**CIDR ranges do not work.** An operating system bypass list often contains entries like `10.0.0.0/8` or `192.168.0.0/16`; copying those into `NO_PROXY` has no effect. Use host names or domain suffixes instead. + +You do not need to list `localhost` or `127.0.0.1`. DSH always bypasses loopback, because its own Web UI and local servers would otherwise route through the proxy and loop. + +## Limits worth knowing + +**SOCKS proxies are not supported.** A `socks5://` value is reported at startup and skipped, and DSH connects directly. Point the variables at your proxy application's HTTP port instead — most expose both, and the HTTP one is usually a neighbouring port number. + +**`ALL_PROXY` alone is enough.** DSH falls back to it for both schemes, even though Node and curl differ on this. Setting `HTTPS_PROXY` explicitly is still clearer. + +**A TLS-intercepting corporate proxy needs its certificate.** If requests fail with a certificate error once the proxy is reachable, point Node at your organisation's CA bundle before launching: + +```sh +export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem +``` + +Node reads that variable only at process start, so export it before running `dsh`. + +**Tools DSH runs for you follow the same proxy.** Commands in the bash tool, `git`, `gh`, and MCP servers started as child processes all inherit these variables. A child that is itself a Node program honors them only on Node 22.21 or later; an older Node connects directly. + +## Check that it worked + +Ask the agent to fetch a page and watch your proxy application's connection log: + +```sh +dsh --profile headless "fetch https://example.com and tell me the page title" +``` + +If the request does not appear there, confirm the variables survive into DSH's own environment: + +```sh +env | grep -i proxy +``` diff --git a/docs/user/guide/network-proxy.zh.md b/docs/user/guide/network-proxy.zh.md new file mode 100644 index 0000000000..82aa63bb04 --- /dev/null +++ b/docs/user/guide/network-proxy.zh.md @@ -0,0 +1,74 @@ +# 在网络代理后面运行 DSH + +[English](network-proxy.md) | 中文 + +DSH 会把每一个出站请求——模型调用、web 搜索、页面抓取、走 HTTP 的 MCP 服务器与遥测——都经由标准代理环境变量所指定的代理发出。它在启动时读取这些变量,不需要其他配置。 + +## 导出环境变量 + +```sh +export HTTPS_PROXY=http://127.0.0.1:7890 +export HTTP_PROXY=http://127.0.0.1:7890 +``` + +把这两行写进 shell 配置,这样每次调用 `dsh` 都会继承它们。DSH 还会读取启动目录与 `$DSH_HOME` 下的 `.env` 文件,因此只对某个项目生效的代理可以写在那里;真实环境变量始终优先于文件。 + +需要凭据的代理把凭据写在 URL 里:`http://user:password@proxy.example:8080`。DSH 绝不会回显密码——诊断信息中出现的代理会显示用户名并掩去其余部分。 + +## 为什么浏览器走代理、终端却不走 + +这是最常见的意外,而且并非 DSH 特有。**根本不存在一个所有软件都遵循的"系统代理"**——实际上有三套互不相干的机制: + +| 机制 | 谁会遵循 | +|---|---| +| 操作系统的代理设置 | Safari、绝大多数 macOS 原生应用、Chrome 与 Edge | +| `HTTP_PROXY` / `HTTPS_PROXY` 环境变量 | `curl`、`git`、`npm`、`pip` 以及 DSH | +| TUN 模式(虚拟网卡) | 所有程序,且对应用透明 | + +Clash 这类代理软件里的"系统代理"开关只写第一套。浏览器会读到它,命令行工具则永远看不到。这就是为什么导出环境变量是一个独立步骤,也是为什么打开 TUN 模式后两者都能工作、且完全不需要变量。 + +DSH 不读取操作系统的代理设置。请导出环境变量,或使用 TUN 模式。 + +## 指定哪些目标保持直连 + +`NO_PROXY` 列出需要直连的主机: + +```sh +export NO_PROXY=internal.example.com,.corp.example.com,registry.local +``` + +一个条目可匹配精确主机、`.suffix` 或 `*.suffix` 域名、可选的 `:port`,或用 `*` 匹配全部。 + +**CIDR 网段不生效。** 操作系统的绕过列表常含 `10.0.0.0/8` 或 `192.168.0.0/16` 这类条目;把它们复制进 `NO_PROXY` 不会有任何效果。请改用主机名或域名后缀。 + +不需要列出 `localhost` 或 `127.0.0.1`。DSH 始终绕过 loopback,否则它自己的 Web UI 与本地服务器都会经由代理并形成回环。 + +## 值得知道的限制 + +**不支持 SOCKS 代理。** `socks5://` 形式的值会在启动时被报告并跳过,DSH 转为直连。请把变量指向代理软件的 HTTP 端口——多数软件两者都提供,且 HTTP 端口通常就在相邻的端口号上。 + +**只设 `ALL_PROXY` 也够用。** DSH 会用它为两种协议兜底,尽管 Node 与 curl 在这一点上并不一致。显式设置 `HTTPS_PROXY` 仍然更清楚。 + +**做 TLS 拦截的企业代理需要它的证书。** 如果代理已经可达但请求仍报证书错误,请在启动前把 Node 指向你所在组织的 CA 包: + +```sh +export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem +``` + +Node 只在进程启动时读取该变量,所以要在运行 `dsh` 之前导出。 + +**DSH 替你运行的工具遵循同一个代理。** bash 工具里的命令、`git`、`gh`,以及作为子进程启动的 MCP 服务器都会继承这些变量。子进程若本身是 Node 程序,则需 Node 22.21 或更高版本才会遵循;更旧的 Node 会直连。 + +## 验证是否生效 + +让 agent 抓取一个页面,同时观察代理软件的连接日志: + +```sh +dsh --profile headless "fetch https://example.com and tell me the page title" +``` + +如果请求没有出现在那里,确认变量确实进入了 DSH 自己的环境: + +```sh +env | grep -i proxy +``` diff --git a/package.json b/package.json index 9587f220d0..44d510f532 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts", "verify-client-packages": "tsx scripts/verify-client-packages.ts", "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", + "verify-no-bare-dispatcher": "tsx scripts/verify-no-bare-dispatcher.ts", "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index 158a9ead5c..9abca61d62 100644 --- a/packages/README.i18n.yaml +++ b/packages/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/README.md -README.md: e1a729a6c80d8f8125e0d4c4b038dc631edd7a26 -README.zh.md: 754ad0fc44677afb243c3a32a0281f58ec3b3af2 +README.md: 032c18f301846b6e80c89b5f208e344d954b336b +README.zh.md: 86c3ce9b19812bcb21c6bb7440ee96f5075922f4 diff --git a/packages/README.md b/packages/README.md index e1a729a6c8..032c18f301 100644 --- a/packages/README.md +++ b/packages/README.md @@ -53,6 +53,7 @@ Every package lives in exactly one group; new packages join existing groups, and | [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | | [`webhook/`](webhook/README.md) | Verified external events, trusted rules, and fire-and-forget Workspace Sessions | | [`web/`](web/README.md) | Web capability family: seam, search/fetch providers, model-facing web tools | +| [`net/`](net/README.md) | Process-wide outbound transport policy: the HTTP proxy every request inherits | | [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | diff --git a/packages/README.zh.md b/packages/README.zh.md index 754ad0fc44..86c3ce9b19 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -53,6 +53,7 @@ harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会 | [`workflow/`](workflow/README.zh.md) | 工作流 seam、worker 线程引擎、面向模型的 `workflow`/`ralph` 工具 | | [`webhook/`](webhook/README.zh.md) | 已验证外部事件、受信规则与即发即弃 Workspace Session | | [`web/`](web/README.zh.md) | Web 能力系列:seam、搜索/获取提供方、面向模型的 Web 工具 | +| [`net/`](net/README.zh.md) | 进程级出站传输策略:每个请求都会继承的 HTTP 代理 | | [`attachment/`](attachment/README.zh.md) | 持久附件标识、校验、本地内容寻址存储 | | [`spill/`](spill/README.zh.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | | [`todo/`](todo/README.zh.md) | 面向模型的 `todo_write` 工具 | diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index a94691bbfe..1d65f2de80 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -32,18 +32,21 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" }, "dependencies": { "e2b": "2.29.1", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-fs-e2b": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", @@ -53,7 +56,6 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-subprocess-e2b": "workspace:^", "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-terminal-bash": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-terminal-bash": "workspace:^" } } diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 18c9a7c5db..428f7b25c1 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -9,6 +9,7 @@ import { posix } from 'node:path' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { FileType, Sandbox, SandboxNotFoundError } from 'e2b' +import { proxyUrlFor } from '@deepseek-ai/dsh-http-proxy' export { CommandExitError, @@ -66,6 +67,9 @@ declare module '@deepseek-ai/cordis' { } } +/** The SDK's own default control-plane domain; `E2B_DOMAIN` overrides it there and here alike. */ +const E2B_DEFAULT_DOMAIN = 'e2b.app' + /** * Creates one lazily consumable E2B SDK handle and deletes the sandbox at * timeout or disposal. Creation begins at plugin construction; adapters await @@ -149,11 +153,16 @@ export class E2BRuntime extends Service { } private async open(): Promise { + // The SDK builds its own undici dispatcher, so the global one never reaches it; it takes a proxy + // URL instead and reads no environment of its own. Its control-plane origin follows `E2B_DOMAIN` + // exactly as the SDK derives it, so a bypass entry naming that host is honored. + const proxy = proxyUrlFor(new URL(`https://api.${process.env.E2B_DOMAIN ?? E2B_DEFAULT_DOMAIN}`)) const sandbox = await Sandbox.create({ apiKey: this.config.apiKey, timeoutMs: this.config.timeoutMs, secure: true, lifecycle: { onTimeout: 'kill' }, + ...proxy === undefined ? {} : { proxy }, }) try { await sandbox.files.makeDir(this.cwd) diff --git a/packages/e2b/e2b/tests/egress.spec.ts b/packages/e2b/e2b/tests/egress.spec.ts new file mode 100644 index 0000000000..ff295cba16 --- /dev/null +++ b/packages/e2b/e2b/tests/egress.spec.ts @@ -0,0 +1,46 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { Context } from '@deepseek-ai/cordis' +import E2bRuntime from '../src/index.ts' + +describe('e2b egress', () => { + it('reaches the control plane through the proxy', async () => { + const observed = await observe(async () => { + const ctx = new Context() + const fiber = await ctx.plugin(E2bRuntime, { apiKey: `e2b_${'0'.repeat(40)}`, cwd: '/home/user', timeoutMs: 5_000 }) + await ctx.e2b.getSandbox().catch(() => undefined) + await fiber.dispose() + }) + expect(observed.join('|')).toContain('api.e2b.app:443') + }) +}) diff --git a/packages/e2b/e2b/tsconfig.json b/packages/e2b/e2b/tsconfig.json index ff089e1e58..16b08d6ea5 100644 --- a/packages/e2b/e2b/tsconfig.json +++ b/packages/e2b/e2b/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "../../../vendor/cosmokit" @@ -20,6 +22,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index c8c3963f28..171c0d9ae1 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -48,16 +48,17 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-timeout": "workspace:^" } } diff --git a/packages/llm/llm-pi-ai/tests/egress.spec.ts b/packages/llm/llm-pi-ai/tests/egress.spec.ts new file mode 100644 index 0000000000..d1c23e13f5 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/egress.spec.ts @@ -0,0 +1,39 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { discoverModels } from '../src/discovery.ts' +describe('pi-ai discovery egress', () => { + it('goes through the proxy', async () => { + const observed = await observe(() => discoverModels({ baseURL: 'http://pi-probe.invalid/v1', api: 'openai-completions', apiKey: 'probe' })) + expect(observed.join('|')).toContain('pi-probe.invalid') + }) +}) diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index 8200210b6d..779d3dd0df 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -43,6 +43,9 @@ }, { "path": "../../util/timeout" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index fed592a473..3b8ced5089 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -47,8 +47,10 @@ "zod": "^4.4.3" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-attachment-local": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -56,7 +58,6 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", - "@modelcontextprotocol/server-filesystem": "^2026.7.4", - "@deepseek-ai/cordis": "workspace:^" + "@modelcontextprotocol/server-filesystem": "^2026.7.4" } } diff --git a/packages/mcp/mcp-client/tests/egress.spec.ts b/packages/mcp/mcp-client/tests/egress.spec.ts new file mode 100644 index 0000000000..df0894a4b1 --- /dev/null +++ b/packages/mcp/mcp-client/tests/egress.spec.ts @@ -0,0 +1,40 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +describe('mcp streamable-http egress', () => { + it('goes through the proxy', async () => { + const t = new StreamableHTTPClientTransport(new URL('http://mcp-probe.invalid/mcp')) + const observed = await observe(() => t.send({ jsonrpc: '2.0', id: 1, method: 'ping' })) + expect(observed.join('|')).toContain('mcp-probe.invalid') + }) +}) diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index b48aea0922..e98771f654 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -4,7 +4,9 @@ "rootDir": "src", "outDir": "lib/types" }, - "include": ["src"], + "include": [ + "src" + ], "references": [ { "path": "../../../vendor/cosmokit" @@ -32,6 +34,9 @@ }, { "path": "../../util/timeout" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/net/README.i18n.yaml b/packages/net/README.i18n.yaml new file mode 100644 index 0000000000..8f749438c8 --- /dev/null +++ b/packages/net/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/net/README.md +README.md: 21aa924afd94e3232b67a3648ca236fd8396b437 +README.zh.md: 9dec251f6e5b546d6fc8308eb8c13754a65c562b diff --git a/packages/net/README.md b/packages/net/README.md new file mode 100644 index 0000000000..21aa924afd --- /dev/null +++ b/packages/net/README.md @@ -0,0 +1,44 @@ +--- +description: "Package map for the network group: process-wide outbound transport policy that applies to every request the harness makes." +kind: "package-group" +--- + +# net/ — outbound network transport + +English | [中文](README.zh.md) + +## Summary + +The `net/` group owns transport-level decisions that apply to every outbound request the harness makes, regardless of which capability makes it. Today that is one decision — whether a request goes through an HTTP proxy — and one package that owns it. The group exists because such a decision belongs to no single capability: an LLM adapter, a web-search backend, an MCP transport, and a telemetry exporter all inherit it without knowing about each other, and putting it inside any of their groups would make the other three depend backwards. These packages are not capability seams: transport policy has one implementation and one answer per process, so there is nothing to swap. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + +----- + + +## Packages + +| Package | Role | ctx key | +|---|---|---| +| [`http-proxy/`](http-proxy/README.md) | Resolves one outbound proxy policy and installs it as the process's global dispatcher | none — installed by the launcher | + +----- + + +## Related documentation + +- [Network proxy guide](../../docs/user/guide/network-proxy.md) — the user-facing page: what to export, and why a browser is proxied when a terminal is not. + + +## Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/net/README.zh.md b/packages/net/README.zh.md new file mode 100644 index 0000000000..9dec251f6e --- /dev/null +++ b/packages/net/README.zh.md @@ -0,0 +1,44 @@ +--- +description: "network 组的包地图:适用于 Harness 发出的每一个请求的进程级出站传输策略。" +kind: "package-group" +--- + +# net/ — 出站网络传输 + +[English](README.md) | 中文 + +## 概述 + +`net/` 组负责传输层面的决策——它们适用于 Harness 发出的每一个出站请求,与由哪个能力发起无关。目前这样的决策只有一个:请求是否经由 HTTP 代理;对应的包也只有一个。该组之所以存在,是因为这类决策不属于任何单一能力:LLM(大语言模型)适配器、web 搜索后端、MCP 传输与遥测导出器都在彼此无感的情况下继承它,而把它放进其中任何一组,都会让另外三组产生反向依赖。这些包不是能力接缝:传输策略每个进程只有一种实现、一个答案,没有可替换的对象。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + +----- + + +## 包 + +| 包 | 角色 | ctx key | +|---|---|---| +| [`http-proxy/`](http-proxy/README.zh.md) | 解析一份出站代理策略,并将其装为进程的全局 dispatcher | 无——由启动器安装 | + +----- + + +## 相关文档 + +- [网络代理指南](../../docs/user/guide/network-proxy.zh.md)——面向用户的页面:需要导出什么,以及为什么浏览器走代理而终端不走。 + + +## 开发备注 + +
+面向维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/net/http-proxy/README.i18n.yaml b/packages/net/http-proxy/README.i18n.yaml new file mode 100644 index 0000000000..eb6ba9e4c9 --- /dev/null +++ b/packages/net/http-proxy/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/net/http-proxy/README.md +README.md: 17c92824b70bb017b11a0635edbdbbad9e8f48ae +README.zh.md: b18db4e5a91edc499b33369c13f62b2fbba374ca diff --git a/packages/net/http-proxy/README.md b/packages/net/http-proxy/README.md new file mode 100644 index 0000000000..17c92824b7 --- /dev/null +++ b/packages/net/http-proxy/README.md @@ -0,0 +1,117 @@ +--- +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." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-http-proxy + +English | [中文](README.zh.md) + +## Summary + +Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`, so a harness behind a proxy would connect directly no matter what the user exported — the LLM request, every web search, MCP over HTTP, telemetry, and the sandbox SDK alike. This package resolves one proxy policy from the launcher's environment snapshot and installs it as undici's global dispatcher, which is exactly what `fetch` resolves. Ordinary call sites therefore need no change and no import: they write `fetch()` and are proxied. The package also owns the three places a global dispatcher cannot reach — a caller that needs its own agent options, a worker thread with its own `globalThis`, and a spawned child Node — and gives each one a single supported way through. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Nothing to mount. The `dsh` launcher resolves and installs the policy for every profile before the first plugin loads, so a user who exports `HTTPS_PROXY` is proxied everywhere. Mount the plugin only when a composition wants the policy declared in `cordis.yml` instead of the environment. + +### Writing a new outbound call + +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. An SDK that builds its own transport does **not**, and two of the ones this repository ships turned out to: the OTLP exporter posts through `node:http`, and the E2B SDK constructs its own undici dispatcher. Assume nothing about an SDK; check it. + +| You are writing | Use | +|---|---| +| A call needing its own agent options (pool size, timeouts, a DNS lookup) | `createDispatcher(url, options)` | +| An SDK that takes a `node:http` agent | `createNodeHttpAgent(protocol, options)` | +| An SDK that takes a proxy URL of its own | `proxyUrlFor(url)` | +| A worker thread, or a spawn whose environment you build yourself | merge `childProxyEnv()` into its environment | + +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; a line that must genuinely ignore the proxy says so with a `proxy-exempt:` comment. + +That gate cannot see inside an SDK, so every outbound call site in the repository also carries an `egress.spec.ts` that drives its real code path through a fake proxy and asserts the proxy saw the request. A new call site adds one. It is the only thing that catches an SDK changing transports underneath us — which is exactly how the OTLP and E2B gaps were found. + +### What the policy reads + +`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, so a proxy declared in a project or `$DSH_HOME` `.env` layer works too; real environment variables still outrank both. + +Loopback is always bypassed. The harness's own Web UI, Connection transport, and every local test server would otherwise route through the proxy and loop. + +### Failures + +A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable string, an unsupported scheme — is reported and skipped, and the process connects directly. That variable may have been exported for other tools, so it must not stop the agent from starting. The same value supplied through this plugin's `Config` throws at load instead: that is the harness's own configuration surface, where a typo has to be loud. + +----- + + +## Understand the implementation + +### Design philosophy + +**One resolution, two readers.** `proxyForUrl()` and the installed dispatcher must never disagree about a URL, or `dsh-web-fetch-http` would pin a connection the dispatcher meant to tunnel. Installation therefore publishes the resolved policy into the proxy environment variables and constructs `EnvHttpProxyAgent` with no options, so the agent reads back exactly what was resolved rather than re-parsing the raw environment under slightly different rules. + +**Publishing the policy is also how children inherit it.** The same write normalizes what every spawned process sees: the `ALL_PROXY` fallback becomes a concrete `HTTP_PROXY`, and the bypass list arrives with loopback already merged. + +### Source map + +| File | Holds | +|---|---| +| `src/policy.ts` | Resolution, bypass matching, and redaction. Imports no transport, so it stays loadable where undici is absent. | +| `src/install.ts` | The global dispatcher, the active-policy record, `createDispatcher`, and `childProxyEnv`. Imports undici dynamically. | +| `src/index.ts` | Re-exports both halves and the optional Cordis plugin. | + +### Bypass matching + +An entry matches an exact host, a `.suffix` or `*.suffix` domain, an optional `:port`, or `*` for 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. + +----- + + +## Further Exploration + +- [Network proxy guide](../../../docs/user/guide/network-proxy.md) — what to export, and why a browser is proxied when a terminal is not. +- [`dsh-web-fetch-http`](../../web/web-fetch-http/README.md) — the one consumer whose safety rules change under a proxy. + +----- + + +## Model Experience + +None, as transport policy only: it changes how bytes reach the network and registers no prompt, schema, or result text. + +#### KV Cache effect + +No direct invalidation: the package contributes no request tokens and never mutates a request prefix, so provider cache reuse is unaffected. + +## Known Limitations and Deferred Work + + + + +These limits define when the package is a poor fit. They are current package constraints. + +- **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment or configuration. 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 skipped rather than silently ignored. +- **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. +- **A separate Node context matches bypass entries by Node's rules, not these** — a child process or worker thread honors the policy through Node's own `NODE_USE_ENV_PROXY` support, whose `NO_PROXY` parsing differs in separators and IPv4-range handling, and which exists only on Node 22.21+ and 24+. An older runtime keeps that context direct. +- **The `code-runtime` worker is deliberately excluded** — model-authored programs run with no ambient environment at all, and a proxy URL may carry credentials. + + +### Dev Note + +
+Working context for maintainers — click to expand + +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. + +
diff --git a/packages/net/http-proxy/README.zh.md b/packages/net/http-proxy/README.zh.md new file mode 100644 index 0000000000..b18db4e5a9 --- /dev/null +++ b/packages/net/http-proxy/README.zh.md @@ -0,0 +1,117 @@ +--- +description: "Harness 的出站 HTTP 代理支持:从启动环境解析出的一份策略,如何覆盖到 Node fetch 本来会直连的每一个请求。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-http-proxy + +[English](README.md) | 中文 + +## 概述 + +Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代理后面运行的 Harness 无论用户导出了什么都会直连——LLM(大语言模型)请求、每次 web 搜索、走 HTTP 的 MCP、遥测与沙箱 SDK 一概如此。本包从启动器的环境快照解析出一份代理策略,并把它装成 undici 的全局 dispatcher,而这正是 `fetch` 解析的对象。因此普通调用点无需改动、也无需引入本包:写 `fetch()` 就已经走代理。本包还负责全局 dispatcher 覆盖不到的三处——需要自定义 agent 选项的调用方、拥有独立 `globalThis` 的 worker 线程、以及派生出的子 Node 进程——并为每一处给出唯一受支持的走法。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +无需挂载。`dsh` 启动器会在第一个插件加载之前,为每个 profile 解析并安装策略,因此导出了 `HTTPS_PROXY` 的用户在所有位置都会走代理。只有当某个组合希望把策略写在 `cordis.yml` 而非环境中时,才需要挂载本插件。 + +### 编写新的出站调用 + +普通 `fetch()` 已经走代理,任何最终落到 `globalThis.fetch` 的 SDK 也一样——MCP HTTP 传输与 pi-ai 提供方栈都是如此。但自建传输的 SDK **不会**,而本仓库随附的 SDK 里就有两个属于此类:OTLP 导出器通过 `node:http` 投递,E2B SDK 自建 undici dispatcher。不要对任何 SDK 想当然,去查。 + +| 你要写的东西 | 使用 | +|---|---| +| 需要自定义 agent 选项的调用(连接池、超时、DNS 查询) | `createDispatcher(url, options)` | +| 接受 `node:http` agent 的 SDK | `createNodeHttpAgent(protocol, options)` | +| 接受自有代理 URL 的 SDK | `proxyUrlFor(url)` | +| worker 线程,或由你自己构造环境的派生进程 | 把 `childProxyEnv()` 并入其环境 | + +构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法;确实必须忽略代理的行用 `proxy-exempt:` 注释说明理由。 + +该门禁看不进 SDK 内部,因此仓库中每一个出网点都另有一份 `egress.spec.ts`:它驱动该点的真实代码路径穿过一个假代理,并断言代理确实收到了请求。新增出网点就补一份。它是唯一能发现 SDK 在我们脚下更换传输的手段——OTLP 与 E2B 这两个漏洞正是这样被发现的。 + +### 策略读取哪些值 + +`http_proxy`、`https_proxy`、`no_proxy` 与 `all_proxy`,小写优先、大写兜底,空值视为未设置。`ALL_PROXY` 为两种协议兜底,HTTPS 最后回退到 HTTP 代理——其中第一条 Node 与 undici 都不会自行推导。取值来自启动器的快照,因此写在项目或 `$DSH_HOME` 的 `.env` 层中的代理同样生效;真实环境变量仍然高于两者。 + +loopback 始终被绕过。否则 Harness 自己的 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。 + +### 失败处理 + +本包无法使用的代理值——SOCKS 或 PAC URL、无法解析的字符串、不受支持的协议——会被报告并跳过,进程转为直连。该变量可能是用户为其他工具导出的,不应因此阻止 agent 启动。同样的值若通过本插件的 `Config` 提供,则在加载期抛出:那是 Harness 自己的配置面,笔误必须立刻响。 + +----- + + +## 理解实现 + +### 设计理念 + +**一次解析,两个读者。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此安装时会把解析出的策略发布到代理环境变量中,并以无选项方式构造 `EnvHttpProxyAgent`,让该 agent 读回的正是解析结果,而不是按略有差异的规则重新解析原始环境。 + +**发布策略同时也是子进程继承的途径。** 同一次写入还规范化了每个派生进程看到的内容:`ALL_PROXY` 兜底落为具体的 `HTTP_PROXY`,绕过列表也已并入 loopback。 + +### 源码地图 + +| 文件 | 承载 | +|---|---| +| `src/policy.ts` | 解析、绕过匹配与脱敏。不引入任何传输实现,因此在没有 undici 的环境中仍可加载。 | +| `src/install.ts` | 全局 dispatcher、生效策略记录、`createDispatcher` 与 `childProxyEnv`。动态引入 undici。 | +| `src/index.ts` | 重导出两半,以及可选的 Cordis 插件。 | + +### 绕过匹配 + +一个条目可匹配精确主机、`.suffix` 或 `*.suffix` 域名、可选的 `:port`,或用 `*` 匹配全部。带方括号与裸写的 IPv6 字面量都能匹配——裸写的 `::1` **不会**被读成主机 `:` 端口 `1`,而 undici 自带的匹配器正是这样出错的,这也是解析结果中同时携带 `::1` 与 `[::1]` 的原因。CIDR 不参与匹配:操作系统的绕过列表常含 `10.0.0.0/8`,必须改写成后缀形式。 + +----- + + +## 进一步探索 + +- [网络代理指南](../../../docs/user/guide/network-proxy.zh.md)——需要导出什么,以及为什么浏览器走代理而终端不走。 +- [`dsh-web-fetch-http`](../../web/web-fetch-http/README.zh.md)——唯一一个安全规则会因代理而改变的消费方。 + +----- + + +## 模型体验 + +无。本包只承担传输策略:它改变字节如何抵达网络,不注册任何提示词、schema 或结果文本。 + +#### KV Cache 影响 + +不会直接失效:本包不贡献任何请求 token,也从不改变请求前缀,因此提供方缓存复用不受影响。 + +## 已知限制与延期工作 + + + + +这些限制界定了本包不适用的场景,属于当前的包级约束。 + +- **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境或配置的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告并跳过,而不是静默忽略。 +- **不支持自定义证书颁发机构**——做 TLS 拦截的企业代理需要在启动前为进程设置 `NODE_EXTRA_CA_CERTS`,本包既不设置也不校验它。 +- **独立的 Node 上下文按 Node 自己的规则匹配绕过条目,而非本包的规则**——子进程或 worker 线程通过 Node 自带的 `NODE_USE_ENV_PROXY` 支持来遵循策略,而它的 `NO_PROXY` 解析在分隔符与 IPv4 区间处理上与此处不同,且仅存在于 Node 22.21+ 与 24+。更旧的运行时会让该上下文保持直连。 +- **`code-runtime` worker 被刻意排除在外**——模型编写的程序运行时完全没有环境变量,而代理 URL 可能携带凭据。 + + +### 开发备注 + +
+面向维护者的工作上下文——点击展开 + +userland undici 能触及 Node 内置的 `fetch`,依赖的是两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合,不是约定——参见 [corepack#834](https://github.com/nodejs/corepack/issues/834) 中它失效的实例。`tests/install.spec.ts` 断言真实请求会抵达一个 loopback 代理,因此破坏该耦合的版本升级会在那里失败,而不是流到线上。 + +
diff --git a/packages/net/http-proxy/package.json b/packages/net/http-proxy/package.json new file mode 100644 index 0000000000..8f675ff505 --- /dev/null +++ b/packages/net/http-proxy/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-http-proxy", + "description": "Process-wide outbound HTTP proxy policy for DeepSeek Harness: resolve it from the launch environment and install it as undici's global dispatcher", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/net/http-proxy" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "undici": "^8.10.0" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^" + } +} diff --git a/packages/net/http-proxy/src/index.ts b/packages/net/http-proxy/src/index.ts new file mode 100644 index 0000000000..2d0efc12b0 --- /dev/null +++ b/packages/net/http-proxy/src/index.ts @@ -0,0 +1,86 @@ +/** + * Outbound HTTP proxy support for DeepSeek Harness. + * + * Node's built-in `fetch` ignores `HTTP_PROXY` and friends, so every harness request would connect + * directly no matter what the user exported. This package resolves one policy from the launch + * environment and installs it as undici's global dispatcher, which is what `fetch` resolves — so + * LLM adapters, web search, MCP over HTTP, telemetry, and sandbox SDKs are all covered without + * touching their code. + * + * The launcher installs the environment-derived policy for every profile. This plugin exists for a + * composition that wants the policy in `cordis.yml` instead: it replaces the launcher's dispatcher + * for as long as it is mounted, and restores it on disposal. It is not part of any shipped bundle, + * so the default path installs exactly once. + * @module @deepseek-ai/dsh-http-proxy + */ + +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { installGlobalProxy } from './install.ts' +import { describeProxyPolicy, resolveProxyPolicy, type ProxyConfig } from './policy.ts' + +export { + bypassesProxy, + describeProxyPolicy, + proxyForUrl, + resolveProxyPolicy, + DIRECT_POLICY, + LOOPBACK_NO_PROXY, + type ProxyConfig, + type ProxyDiagnostic, + type ProxyPolicy, + type ProxyResolution, +} from './policy.ts' + +export { + childProxyEnv, + createDispatcher, + createNodeHttpAgent, + currentProxyPolicy, + installGlobalProxy, + proxyUrlFor, +} from './install.ts' + +/** Cordis plugin name. */ +export const name = 'http-proxy' + +/** Composition-declared proxy settings; every field is optional and the environment outranks them. */ +export interface Config extends ProxyConfig {} + +/** Schema for {@link Config}; a malformed proxy URL here fails the load rather than warning. */ +export const Config: z = z.object({ + mode: z.union([z.const('env'), z.const('custom'), z.const('off')]).description( + 'Whether to resolve from the environment (`env`, the default), do the same for a composition that supplies its own proxy (`custom`), or keep this process direct (`off`).', + ), + httpProxy: z.string().description('Proxy for `http:` origins when the environment supplies none.'), + httpsProxy: z.string().description('Proxy for `https:` origins when the environment supplies none.'), + noProxy: z.string().description('Bypass list when the environment supplies none; loopback is always added.'), +}) + +/** + * Install the composition's proxy policy for as long as this plugin is mounted. + * + * A value this plugin's own `Config` supplied and that failed validation throws: it is the harness's + * configuration surface, where a typo must be loud. A rejected *environment* value only warns, + * because the same variable may have been exported for other tools and must not stop the agent from + * starting. + * + * Installing IS this plugin's lifetime, so the disposer is returned as the startup effect rather than + * registered through `ctx.effect()`: a caller awaiting the mount must observe an installed dispatcher, + * which a separately-scheduled async effect would not guarantee. + * + * @param ctx - the mounting context, read for the launch environment snapshot and the logger. + * @param config - composition-declared settings. + * @returns the disposer restoring the previous dispatcher, policy, and environment. + */ +export async function apply(ctx: Context, config: Config): Promise<() => Promise> { + const { policy, diagnostics } = resolveProxyPolicy(launchEnvironmentOf(ctx), config) + const fatal = diagnostics.filter(diagnostic => diagnostic.origin.startsWith('config.')) + if (fatal.length > 0) { + throw new Error(`http-proxy: ${fatal.map(diagnostic => diagnostic.message).join('; ')}`) + } + for (const diagnostic of diagnostics) ctx.logger.warn('http-proxy: %s', diagnostic.message) + if (policy.source !== 'none') ctx.logger.debug('http-proxy: %s', describeProxyPolicy(policy)) + return await installGlobalProxy(policy) +} diff --git a/packages/net/http-proxy/src/install.ts b/packages/net/http-proxy/src/install.ts new file mode 100644 index 0000000000..81a52bb5fb --- /dev/null +++ b/packages/net/http-proxy/src/install.ts @@ -0,0 +1,185 @@ +/** + * Proxy installation: the transport half of this package. It owns undici's global dispatcher, the + * process-wide record of which policy is active, and the dispatcher factory every other package uses + * instead of constructing a bare agent. + * + * `undici` is imported dynamically so the pure {@link ProxyPolicy} half stays loadable where no Node + * transport exists, matching how `dsh-web-fetch-http` defers its own transport import. + * @module @deepseek-ai/dsh-http-proxy/install + */ + +import type { Agent, Dispatcher } from 'undici' +import { DIRECT_POLICY, proxyForUrl, type ProxyPolicy } from './policy.ts' + +/** + * The environment names each policy field owns, lowercase first. Both casings are written together: + * undici reads the lowercase name first, so leaving a stale uppercase value behind would let it + * shadow the resolved one on Windows, where the two names are the same variable. + */ +const POLICY_ENV_NAMES = { + httpProxy: ['http_proxy', 'HTTP_PROXY'], + httpsProxy: ['https_proxy', 'HTTPS_PROXY'], + noProxy: ['no_proxy', 'NO_PROXY'], +} as const + +/** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */ +let active: ProxyPolicy | undefined + +/** + * The policy governing this process's outbound requests. + * + * @returns the installed policy, or `undefined` when {@link installGlobalProxy} has not run. A caller + * that only needs to route a URL can treat `undefined` as {@link DIRECT_POLICY}. + */ +export function currentProxyPolicy(): ProxyPolicy | undefined { + return active +} + +/** + * Publish a policy through the proxy environment variables so both undici and every spawned child + * observe the one resolved answer — including the `ALL_PROXY` fallback and the merged loopback + * bypass, neither of which they would derive on their own. + * + * @param policy - the policy to publish. + * @returns a function restoring every name this call changed. + */ +function applyPolicyEnv(policy: ProxyPolicy): () => void { + const previous = new Map() + for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) { + const value = policy[field as keyof typeof POLICY_ENV_NAMES] + for (const name of names) { + previous.set(name, process.env[name]) + if (value === undefined || value === '') Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } + } + return () => { + for (const [name, value] of previous) { + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } + } +} + +/** + * Route this process's outbound HTTP through `policy`. + * + * Installing replaces undici's global dispatcher, which is what Node's built-in `fetch` resolves, so + * every caller that issues a plain `fetch()` is covered without knowing this package exists. A policy + * that proxies nothing installs no dispatcher and leaves the environment untouched. + * + * Worker threads do not inherit the global dispatcher; each one calls this with the policy its host + * passed through `workerData`. + * + * @param policy - the resolved policy to install. + * @returns a disposer restoring the previous dispatcher, policy, and environment, then closing the agent. + */ +export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise> { + const previousPolicy = active + if (policy.source === 'none') { + active = policy + return () => { + active = previousPolicy + return Promise.resolve() + } + } + const restoreEnv = applyPolicyEnv(policy) + const { EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } = await import('undici') + const previousDispatcher = getGlobalDispatcher() + // Constructed with no options on purpose: it reads the names applyPolicyEnv just wrote, so the + // agent and `proxyForUrl` answer from the same values instead of each parsing the raw environment. + const agent = new EnvHttpProxyAgent() + setGlobalDispatcher(agent) + active = policy + return async () => { + setGlobalDispatcher(previousDispatcher) + active = previousPolicy + restoreEnv() + await agent.close() + } +} + +/** + * Build a dispatcher for one request URL that honors the active policy. + * + * Use this wherever a call site needs its own agent options — connection limits, timeouts, a custom + * DNS lookup. Constructing `new Agent(...)` directly and passing it as `dispatcher` silently bypasses + * the global one and therefore the proxy, which is the defect this function exists to prevent. + * `verify-no-bare-dispatcher` enforces that outside this package. + * + * @param url - the request URL, which decides whether the policy proxies or bypasses it. + * @param options - agent options; applied to whichever agent the policy selects. + * @returns a dispatcher the caller owns and must close once the response body is consumed. + */ +export async function createDispatcher(url: URL, options: Agent.Options = {}): Promise { + const undici = await import('undici') + const proxy = proxyForUrl(active ?? DIRECT_POLICY, url) + if (proxy === undefined) return new undici.Agent(options) + return new undici.ProxyAgent({ ...options, uri: proxy }) +} + +/** + * Build a `node:http` or `node:https` Agent that honors the active policy. + * + * The global dispatcher reaches undici, and therefore `fetch`, but not `node:http`. An SDK that + * issues requests through the core modules — the OTLP exporter is the one this repository ships — + * accepts an agent instead, and this is the agent to give it. + * + * Node's own `proxyEnv` option does the routing, reading the names {@link installGlobalProxy} + * published. It reaches Node 22.21+ and 24.5+; an older runtime ignores the unknown option and + * connects directly, the same seam a spawned child Node has. + * + * @param protocol - the target's protocol, `https:` selecting the TLS agent. + * @param options - agent options merged under the proxy routing. + * @returns an agent the caller passes to the SDK that needs one. + */ +export async function createNodeHttpAgent( + protocol: string, + options: Readonly> = {}, +): Promise { + const core = protocol === 'https:' ? await import('node:https') : await import('node:http') + const proxied = active !== undefined && active.source !== 'none' + // `proxyEnv` postdates the @types/node this workspace pins, so the option is applied through a + // widened record rather than the typed constructor overload. + const agentOptions = { ...options, ...proxied ? { proxyEnv: process.env } : {} } + return new core.Agent(agentOptions as ConstructorParameters[0]) +} + +/** + * The proxy this URL is reached through, for an SDK that takes a proxy URL of its own rather than a + * dispatcher or an agent. `undefined` means the SDK should connect directly. + * + * @param url - the endpoint the SDK will call. + * @returns the proxy URL to hand the SDK, or `undefined` for a direct connection. + */ +export function proxyUrlFor(url: URL): string | undefined { + return proxyForUrl(active ?? DIRECT_POLICY, url) +} + +/** + * The proxy environment a separate Node execution context needs: the resolved policy plus the flag + * that makes Node's built-in HTTP clients honor it. + * + * This covers both shapes DSH spawns. A child process inherits the parent environment, so the proxy + * names merely restate what {@link installGlobalProxy} already published and the flag is what it + * gains. A worker thread is given an explicit, near-empty environment instead, so it needs the names + * as well — and worker threads do not inherit the global dispatcher, which is why they are handled + * here rather than left to the parent's installation. + * + * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that context direct. Such a + * context also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this + * package's in their separators and IPv4-range support. Non-Node children (curl, git, pnpm) ignore + * the flag and read the variables themselves. + * + * @returns names to merge into the child or worker environment, or an empty object when no proxy is active. + */ +export function childProxyEnv(): Record { + if (active === undefined || active.source === 'none') return {} + const env: Record = { NODE_USE_ENV_PROXY: '1' } + for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) { + const value = active[field as keyof typeof POLICY_ENV_NAMES] + if (value === undefined || value === '') continue + for (const name of names) env[name] = value + } + return env +} diff --git a/packages/net/http-proxy/src/invariant.ts b/packages/net/http-proxy/src/invariant.ts new file mode 100644 index 0000000000..44d54652db --- /dev/null +++ b/packages/net/http-proxy/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-http-proxy`. + * @module @deepseek-ai/dsh-http-proxy/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-http-proxy' + +/** Cordis companion plugin name. */ +export const name = 'http-proxy-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package owns no event stream, and its one piece of mutable state — the + * active policy — is asserted against the dispatcher it installs by unit tests that dispose the + * registration and observe a real loopback proxy. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/net/http-proxy/src/policy.ts b/packages/net/http-proxy/src/policy.ts new file mode 100644 index 0000000000..07e47d9f4d --- /dev/null +++ b/packages/net/http-proxy/src/policy.ts @@ -0,0 +1,302 @@ +/** + * Proxy policy resolution: the pure, transport-free half of this package. It turns the launch + * environment plus optional configuration into one {@link ProxyPolicy}, and answers which proxy + * (if any) a given URL goes through. + * + * Nothing here imports `undici`, so the module stays loadable in the browser-worker runtime that + * evaluates `dsh-web-fetch-http` without a Node transport. + * @module @deepseek-ai/dsh-http-proxy/policy + */ + +import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' + +/** + * Loopback entries merged into every policy's `noProxy`. A proxy that also serves the harness's own + * loopback traffic turns the Web UI, the Connection transport, and every local test server into a + * routing loop, so the bypass is not optional. + * + * `::1` and `[::1]` are both listed because the resolved string is also handed to undici, whose + * matcher reads a bare `::1` as host `:` port `1` and therefore never bypasses it. + */ +export const LOOPBACK_NO_PROXY: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]'] + +/** Proxy URL schemes this package routes through. Everything else is reported, never silently dropped. */ +const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:']) + +/** Schemes recognised well enough to name in a diagnostic instead of calling them malformed. */ +const SOCKS_PROTOCOLS = new Set(['socks:', 'socks4:', 'socks4a:', 'socks5:', 'socks5h:']) + +/** + * One resolved outbound proxy policy. Plain data with no methods: worker threads receive it through + * `workerData`'s structured clone, so both sides run the identical policy rather than each re-reading + * an environment they may not share. + */ +export interface ProxyPolicy { + /** Proxy for `http:` origins, or absent for a direct connection. Always a validated `http(s):` URL. */ + readonly httpProxy?: string + /** Proxy for `https:` origins, or absent for a direct connection. Always a validated `http(s):` URL. */ + readonly httpsProxy?: string + /** The bypass list, already merged with {@link LOOPBACK_NO_PROXY}. Empty when nothing is bypassed. */ + readonly noProxy: string + /** Which layer supplied the winning proxy URL; `env` when either field came from the environment. */ + readonly source: 'env' | 'config' | 'none' +} + +/** A policy that proxies nothing. Callers that have not installed a policy resolve URLs against this. */ +export const DIRECT_POLICY: ProxyPolicy = { noProxy: '', source: 'none' } + +/** Why one candidate proxy value was not used. Callers decide whether this warns or fails the load. */ +export interface ProxyDiagnostic { + /** `socks` for a SOCKS or PAC URL this package cannot route; `invalid` for anything unparseable. */ + readonly kind: 'socks' | 'invalid' + /** Where the rejected value came from: an environment variable name, or `config.`. */ + readonly origin: string + /** Operator-facing sentence naming the rejection and the way forward. Carries no credential. */ + readonly message: string +} + +/** + * Proxy settings a composition may declare in `cordis.yml`. Real environment variables win over every + * field here except `mode`, which governs whether the environment is consulted at all. + */ +export interface ProxyConfig { + /** + * `env` (default) resolves from the environment and lets the fields below fill the gaps; `custom` + * does the same but is the honest label for a composition that supplies its own proxy; `off` + * ignores every source and keeps the harness's own requests direct. + * + * `off` governs requests this process issues. It does not strip proxy variables from the + * environment child tools inherit, because those belong to the user, not to the harness. + */ + mode?: 'env' | 'custom' | 'off' + /** Proxy for `http:` origins when the environment supplies none. */ + httpProxy?: string + /** Proxy for `https:` origins when the environment supplies none. */ + httpsProxy?: string + /** Bypass list when the environment supplies none. {@link LOOPBACK_NO_PROXY} is merged in regardless. */ + noProxy?: string +} + +/** A resolved policy plus every candidate value that was rejected on the way to it. */ +export interface ProxyResolution { + /** The policy to install. Never carries a rejected value. */ + readonly policy: ProxyPolicy + /** Rejections, in the order the candidates were considered. Empty on a clean resolution. */ + readonly diagnostics: readonly ProxyDiagnostic[] +} + +/** + * Read one environment name in undici's precedence order — lowercase first, uppercase as the + * fallback — treating a blank value as unset. Blank matters: undici's own `??` chain lets an empty + * lowercase name shadow a populated uppercase one. + * + * @param env - the launch environment snapshot to read. + * @param lower - the lowercase variable name. + * @returns the trimmed value and the name that supplied it, or `undefined` when neither is set. + */ +function readEnv( + env: LaunchEnvironmentSnapshot, + lower: string, +): { value: string; name: string } | undefined { + for (const name of [lower, lower.toUpperCase()]) { + const value = env.get(name)?.value.trim() + if (value !== undefined && value !== '') return { value, name } + } + return undefined +} + +/** + * Validate one candidate proxy URL. + * + * @param candidate - the raw value and the origin to name in a diagnostic. + * @param diagnostics - collector the rejection is appended to. + * @returns the candidate when it is a usable `http(s):` proxy URL, otherwise `undefined`. + */ +function acceptProxyUrl( + candidate: { value: string; name: string } | undefined, + diagnostics: ProxyDiagnostic[], +): string | undefined { + if (candidate === undefined) return undefined + const parsed = URL.parse(candidate.value) + if (parsed === null) { + diagnostics.push({ + kind: 'invalid', + origin: candidate.name, + message: `${candidate.name} is not a valid URL; connecting directly`, + }) + return undefined + } + if (SOCKS_PROTOCOLS.has(parsed.protocol)) { + diagnostics.push({ + kind: 'socks', + origin: candidate.name, + message: `${candidate.name} names a SOCKS proxy, which is not supported; set an http:// or https:// proxy URL instead`, + }) + return undefined + } + if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) { + diagnostics.push({ + kind: 'invalid', + origin: candidate.name, + message: `${candidate.name} uses the unsupported ${parsed.protocol}// scheme; set an http:// or https:// proxy URL instead`, + }) + return undefined + } + return candidate.value +} + +/** + * Merge {@link LOOPBACK_NO_PROXY} into a bypass list, preserving the caller's entries and order. + * A list of `*` already bypasses everything and is returned unchanged. + * + * @param noProxy - the bypass list as the environment or configuration supplied it. + * @returns the effective bypass list. + */ +function withLoopback(noProxy: string | undefined): string { + const entries = (noProxy ?? '').split(/[,\s]+/).map(entry => entry.trim()).filter(entry => entry !== '') + if (entries.includes('*')) return '*' + const present = new Set(entries.map(entry => entry.toLowerCase())) + return [...entries, ...LOOPBACK_NO_PROXY.filter(entry => !present.has(entry))].join(',') +} + +/** + * Split one bypass entry into host and optional port. + * + * A bare IPv6 literal carries several colons and no port, so only a single-colon entry splits; + * a bracketed literal takes its port from after the bracket. Getting this wrong is how undici + * turns `::1` into host `:` port `1`. + * + * @param entry - one already-trimmed bypass entry. + * @returns the entry's host and, when it carries one, its port. + */ +function splitHostPort(entry: string): { host: string; port?: string } { + if (entry.startsWith('[')) { + const close = entry.indexOf(']') + if (close !== -1) { + const rest = entry.slice(close + 1) + const host = entry.slice(1, close) + return rest.startsWith(':') ? { host, port: rest.slice(1) } : { host } + } + } + const colon = entry.indexOf(':') + if (colon !== -1 && entry.indexOf(':', colon + 1) === -1) { + return { host: entry.slice(0, colon), port: entry.slice(colon + 1) } + } + return { host: entry } +} + +/** + * Decide whether a bypass list exempts one URL. Entries match an exact host, a `.suffix` or + * `*.suffix` domain, an optional `:port`, or `*` for everything. CIDR notation is not matched — + * an operating system's bypass list often carries `10.0.0.0/8`, which must be rewritten as suffixes. + * + * @param noProxy - the effective bypass list. + * @param url - the request URL. + * @returns true when the URL must bypass the proxy. + */ +export function bypassesProxy(noProxy: string, url: URL): boolean { + // `URL.hostname` keeps the brackets around an IPv6 literal, while a bypass entry may be written + // either way, so both sides are unbracketed before they are compared. + const host = url.hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase() + const port = url.port !== '' ? url.port : url.protocol === 'https:' ? '443' : '80' + for (const raw of noProxy.split(/[,\s]+/)) { + const entry = raw.trim().toLowerCase() + if (entry === '') continue + if (entry === '*') return true + const split = splitHostPort(entry) + if (split.port !== undefined && split.port !== port) continue + const candidate = split.host.replace(/^\*?\./, '').replace(/\.$/, '') + if (candidate === '') continue + if (host === candidate || host.endsWith(`.${candidate}`)) return true + } + return false +} + +/** + * Resolve the outbound proxy policy for this process. + * + * Precedence is environment first, configuration second: a value the user exported wins over one a + * composition declares, and `ALL_PROXY` backs both schemes. HTTPS falls back to the HTTP proxy last, + * matching undici, so this function and the installed dispatcher never disagree about one URL. + * + * @param env - the launch environment snapshot, whose own layering already prefers real variables over `.env` files. + * @param config - optional composition-declared settings. + * @returns the policy to install plus every rejected candidate. + */ +export function resolveProxyPolicy( + env: LaunchEnvironmentSnapshot, + config: ProxyConfig = {}, +): ProxyResolution { + const diagnostics: ProxyDiagnostic[] = [] + if (config.mode === 'off') return { policy: DIRECT_POLICY, diagnostics } + + const all = acceptProxyUrl(readEnv(env, 'all_proxy'), diagnostics) + const httpFromEnv = acceptProxyUrl(readEnv(env, 'http_proxy'), diagnostics) ?? all + const httpsFromEnv = acceptProxyUrl(readEnv(env, 'https_proxy'), diagnostics) ?? all + + const httpFromConfig = acceptProxyUrl( + config.httpProxy === undefined ? undefined : { value: config.httpProxy, name: 'config.httpProxy' }, + diagnostics, + ) + const httpsFromConfig = acceptProxyUrl( + config.httpsProxy === undefined ? undefined : { value: config.httpsProxy, name: 'config.httpsProxy' }, + diagnostics, + ) + + const httpProxy = httpFromEnv ?? httpFromConfig + // HTTPS falls back to the HTTP proxy, so an undefined result here means no layer supplied any + // proxy at all — one check covers both schemes. + const httpsProxy = httpsFromEnv ?? httpsFromConfig ?? httpProxy + if (httpsProxy === undefined) return { policy: DIRECT_POLICY, diagnostics } + + const noProxy = withLoopback(readEnv(env, 'no_proxy')?.value ?? config.noProxy) + const source = httpFromEnv !== undefined || httpsFromEnv !== undefined ? 'env' : 'config' + return { + policy: { + ...httpProxy === undefined ? {} : { httpProxy }, + httpsProxy, + noProxy, + source, + }, + diagnostics, + } +} + +/** + * Resolve which proxy one URL goes through under a policy. + * + * This is the single answer both the installed dispatcher and `dsh-web-fetch-http` consult, so a URL + * can never be pinned to a resolved address by one and tunnelled by the other. + * + * @param policy - the active policy. + * @param url - the request URL. + * @returns the proxy URL to tunnel through, or `undefined` for a direct connection. + */ +export function proxyForUrl(policy: ProxyPolicy, url: URL): string | undefined { + const proxy = url.protocol === 'https:' ? policy.httpsProxy : url.protocol === 'http:' ? policy.httpProxy : undefined + if (proxy === undefined) return undefined + return bypassesProxy(policy.noProxy, url) ? undefined : proxy +} + +/** + * Render a policy for an operator, with any proxy password replaced. The username survives because it + * identifies the account without granting it, which is what makes the line useful in a bug report. + * + * @param policy - a policy whose URLs {@link resolveProxyPolicy} already validated. + * @returns one line naming the effective proxies and bypass list. + */ +export function describeProxyPolicy(policy: ProxyPolicy): string { + if (policy.source === 'none') return 'no proxy (direct)' + const redact = (value: string): string => { + const url = new URL(value) + if (url.password !== '') url.password = '***' + return url.toString() + } + const parts = [ + `http=${policy.httpProxy === undefined ? 'direct' : redact(policy.httpProxy)}`, + `https=${policy.httpsProxy === undefined ? 'direct' : redact(policy.httpsProxy)}`, + `no_proxy=${policy.noProxy}`, + `from=${policy.source}`, + ] + return parts.join(' ') +} diff --git a/packages/net/http-proxy/tests/install.spec.ts b/packages/net/http-proxy/tests/install.spec.ts new file mode 100644 index 0000000000..3050e1f0b5 --- /dev/null +++ b/packages/net/http-proxy/tests/install.spec.ts @@ -0,0 +1,276 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterEach, beforeAll, afterAll, describe, expect, it } from 'vitest' +import { getGlobalDispatcher } from 'undici' +import http from 'node:http' +import https from 'node:https' +import { + childProxyEnv, + createDispatcher, + createNodeHttpAgent, + currentProxyPolicy, + installGlobalProxy, + proxyUrlFor, +} from '../src/install.ts' +import { DIRECT_POLICY, type ProxyPolicy } from '../src/policy.ts' + +/** Absolute-form request targets the fake proxy received; a populated entry proves a request was tunnelled. */ +let proxied: string[] = [] +let proxy: Server +let origin: Server +let proxyUrl: string +let originUrl: string + +function listen(server: Server): Promise { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { resolve(server.address() as AddressInfo) }) + }) +} + +function close(server: Server): Promise { + return new Promise((resolve) => { server.close(() => { resolve() }) }) +} + +beforeAll(async () => { + proxy = createServer((request, response) => { + proxied.push(`${request.method} ${request.url}`) + response.writeHead(200, { 'content-type': 'text/plain' }) + response.end('VIA-PROXY') + }) + origin = createServer((_request, response) => { response.end('DIRECT') }) + const [proxyAddress, originAddress] = await Promise.all([listen(proxy), listen(origin)]) + proxyUrl = `http://127.0.0.1:${String(proxyAddress.port)}` + originUrl = `http://127.0.0.1:${String(originAddress.port)}/probe` +}) + +afterAll(async () => { + await Promise.all([close(proxy), close(origin)]) +}) + +afterEach(() => { + proxied = [] +}) + +/** A policy proxying everything, since the resolved default always bypasses the loopback these tests use. */ +function proxyAll(noProxy = ''): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy, source: 'env' } +} + +describe('installGlobalProxy', () => { + it('routes the built-in global fetch through the proxy', async () => { + const dispose = await installGlobalProxy(proxyAll()) + try { + await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY') + expect(proxied).toEqual([`GET ${originUrl}`]) + } finally { + await dispose() + } + }) + + it('connects directly when the bypass list covers the target', async () => { + const dispose = await installGlobalProxy(proxyAll('127.0.0.1')) + try { + await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT') + expect(proxied).toEqual([]) + } finally { + await dispose() + } + }) + + it('publishes the policy through the proxy environment in both casings', async () => { + const dispose = await installGlobalProxy(proxyAll('example.com')) + try { + expect(process.env.http_proxy).toBe(proxyUrl) + expect(process.env.HTTP_PROXY).toBe(proxyUrl) + expect(process.env.no_proxy).toBe('example.com') + expect(process.env.NO_PROXY).toBe('example.com') + } finally { + await dispose() + } + }) + + it('removes an environment name the policy leaves unset', async () => { + process.env.HTTPS_PROXY = 'http://stale.example' + const dispose = await installGlobalProxy({ httpProxy: proxyUrl, noProxy: '', source: 'env' }) + try { + expect(process.env.HTTPS_PROXY).toBeUndefined() + } finally { + await dispose() + expect(process.env.HTTPS_PROXY).toBe('http://stale.example') + delete process.env.HTTPS_PROXY + } + }) + + it('restores the dispatcher, the policy, and the environment on disposal', async () => { + const before = getGlobalDispatcher() + const beforeEnv = process.env.HTTP_PROXY + const beforePolicy = currentProxyPolicy() + const dispose = await installGlobalProxy(proxyAll()) + expect(getGlobalDispatcher()).not.toBe(before) + expect(currentProxyPolicy()).not.toBe(beforePolicy) + await dispose() + expect(getGlobalDispatcher()).toBe(before) + expect(currentProxyPolicy()).toBe(beforePolicy) + expect(process.env.HTTP_PROXY).toBe(beforeEnv) + await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT') + }) + + it('installs no dispatcher and touches no environment for a direct policy', async () => { + const before = getGlobalDispatcher() + process.env.HTTP_PROXY = 'http://untouched.example' + const dispose = await installGlobalProxy(DIRECT_POLICY) + try { + expect(getGlobalDispatcher()).toBe(before) + expect(process.env.HTTP_PROXY).toBe('http://untouched.example') + expect(currentProxyPolicy()).toBe(DIRECT_POLICY) + } finally { + await dispose() + delete process.env.HTTP_PROXY + } + expect(currentProxyPolicy()).toBeUndefined() + }) +}) + +describe('createDispatcher', () => { + it('tunnels through the proxy when the policy covers the URL', async () => { + const dispose = await installGlobalProxy(proxyAll()) + const dispatcher = await createDispatcher(new URL(originUrl)) + try { + const undici = await import('undici') + const response = await undici.fetch(originUrl, { dispatcher }) + await expect(response.text()).resolves.toBe('VIA-PROXY') + } finally { + await dispatcher.close() + await dispose() + } + }) + + it('connects directly when the policy bypasses the URL', async () => { + const dispose = await installGlobalProxy(proxyAll('127.0.0.1')) + const dispatcher = await createDispatcher(new URL(originUrl)) + try { + const undici = await import('undici') + const response = await undici.fetch(originUrl, { dispatcher }) + await expect(response.text()).resolves.toBe('DIRECT') + expect(proxied).toEqual([]) + } finally { + await dispatcher.close() + await dispose() + } + }) + + it('connects directly when no policy is installed', async () => { + const dispatcher = await createDispatcher(new URL(originUrl)) + try { + const undici = await import('undici') + await expect((await undici.fetch(originUrl, { dispatcher })).text()).resolves.toBe('DIRECT') + } finally { + await dispatcher.close() + } + }) +}) + +describe('childProxyEnv', () => { + it('is empty when no policy is installed', () => { + expect(childProxyEnv()).toEqual({}) + }) + + it('is empty under a direct policy, so a child sees no flag it cannot use', async () => { + const dispose = await installGlobalProxy(DIRECT_POLICY) + try { + expect(childProxyEnv()).toEqual({}) + } finally { + await dispose() + } + }) + + it('carries the resolved policy and the flag that makes a child Node honor it', async () => { + const dispose = await installGlobalProxy(proxyAll('example.com')) + try { + expect(childProxyEnv()).toEqual({ + NODE_USE_ENV_PROXY: '1', + http_proxy: proxyUrl, + HTTP_PROXY: proxyUrl, + https_proxy: proxyUrl, + HTTPS_PROXY: proxyUrl, + no_proxy: 'example.com', + NO_PROXY: 'example.com', + }) + } finally { + await dispose() + } + }) + + it('omits a scheme the policy leaves direct, so a worker inherits no stale name', async () => { + const dispose = await installGlobalProxy({ httpProxy: proxyUrl, noProxy: '', source: 'env' }) + try { + expect(childProxyEnv()).not.toHaveProperty('HTTPS_PROXY') + expect(childProxyEnv()).not.toHaveProperty('NO_PROXY') + } finally { + await dispose() + } + }) +}) + +describe('createNodeHttpAgent', () => { + /** Drive a real `node:http` request, which the global dispatcher never reaches. */ + function get(target: string, agent: http.Agent): Promise { + return new Promise((resolve) => { + http.get(target, { agent }, (response) => { + let body = '' + response.on('data', (chunk: Buffer) => { body += chunk.toString() }) + response.on('end', () => { resolve(body) }) + }).on('error', (error: NodeJS.ErrnoException) => { resolve(`ERR ${error.code ?? ''}`) }) + }) + } + + it('routes a node:http request through the proxy', async () => { + const dispose = await installGlobalProxy(proxyAll()) + const agent = await createNodeHttpAgent('http:') + try { + await expect(get(originUrl, agent)).resolves.toBe('VIA-PROXY') + } finally { + agent.destroy() + await dispose() + } + }) + + it('connects directly when no policy is installed', async () => { + const agent = await createNodeHttpAgent('http:', { keepAlive: false }) + try { + await expect(get(originUrl, agent)).resolves.toBe('DIRECT') + } finally { + agent.destroy() + } + }) + + it('selects the TLS agent for an https target', async () => { + const agent = await createNodeHttpAgent('https:') + try { + expect(agent).toBeInstanceOf(https.Agent) + } finally { + agent.destroy() + } + }) +}) + +describe('proxyUrlFor', () => { + it('names the proxy an SDK with its own transport must use', async () => { + const dispose = await installGlobalProxy(proxyAll()) + try { + expect(proxyUrlFor(new URL('https://api.example.com/v1'))).toBe(proxyUrl) + } finally { + await dispose() + } + }) + + it('names none for a bypassed host, and none at all without a policy', async () => { + const dispose = await installGlobalProxy(proxyAll('api.example.com')) + try { + expect(proxyUrlFor(new URL('https://api.example.com/v1'))).toBeUndefined() + } finally { + await dispose() + } + expect(proxyUrlFor(new URL('https://api.example.com/v1'))).toBeUndefined() + }) +}) diff --git a/packages/net/http-proxy/tests/plugin.spec.ts b/packages/net/http-proxy/tests/plugin.spec.ts new file mode 100644 index 0000000000..b2439c52f1 --- /dev/null +++ b/packages/net/http-proxy/tests/plugin.spec.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import { getGlobalDispatcher } from 'undici' +import * as HttpProxy from '../src/index.ts' +import * as HttpProxyInvariant from '../src/invariant.ts' + +const PROXY = 'http://127.0.0.1:7897' + +/** + * Every proxy name in both casings. The suite clears all of them so a developer's own exported proxy + * cannot decide the outcome — the lowercase names matter most, since resolution reads those first. + */ +const PROXY_ENV_NAMES = [ + 'http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', + 'no_proxy', 'NO_PROXY', 'all_proxy', 'ALL_PROXY', +] as const + +let saved: Record = {} + +beforeEach(() => { + saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]])) + for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name) +}) + +afterEach(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } +}) + +/** The launcher normally provides a snapshot; without one the plugin reads the process environment. */ +function withEnv(values: Record): void { + for (const [name, value] of Object.entries(values)) process.env[name] = value +} + +describe('http-proxy plugin', () => { + it('installs the configured policy and restores the dispatcher on disposal', async () => { + const before = getGlobalDispatcher() + const ctx = new Context() + const fiber = await ctx.plugin(HttpProxy, { httpProxy: PROXY }) + + expect(HttpProxy.currentProxyPolicy()?.httpProxy).toBe(PROXY) + expect(getGlobalDispatcher()).not.toBe(before) + + await fiber.dispose() + expect(HttpProxy.currentProxyPolicy()).toBeUndefined() + expect(getGlobalDispatcher()).toBe(before) + }) + + it('lets a real environment variable outrank the configured proxy', async () => { + withEnv({ HTTP_PROXY: PROXY }) + const ctx = new Context() + const fiber = await ctx.plugin(HttpProxy, { httpProxy: 'http://127.0.0.1:9' }) + try { + expect(HttpProxy.currentProxyPolicy()?.httpProxy).toBe(PROXY) + } finally { + await fiber.dispose() + } + }) + + it('installs nothing under mode off, even with a proxy in the environment', async () => { + withEnv({ HTTP_PROXY: PROXY }) + const before = getGlobalDispatcher() + const ctx = new Context() + const fiber = await ctx.plugin(HttpProxy, { mode: 'off' }) + try { + expect(HttpProxy.currentProxyPolicy()?.source).toBe('none') + expect(getGlobalDispatcher()).toBe(before) + } finally { + await fiber.dispose() + } + }) + + it('reports an unusable environment value and connects directly instead of failing the load', async () => { + withEnv({ HTTP_PROXY: 'socks5://127.0.0.1:1080' }) + const before = getGlobalDispatcher() + const ctx = new Context() + const fiber = await ctx.plugin(HttpProxy, {}) + try { + expect(HttpProxy.currentProxyPolicy()?.source).toBe('none') + expect(getGlobalDispatcher()).toBe(before) + } finally { + await fiber.dispose() + } + }) + + it('fails the load when the composition itself declares an unusable proxy', async () => { + const ctx = new Context() + await expect(ctx.plugin(HttpProxy, { httpsProxy: 'socks5://127.0.0.1:1080' })) + .rejects.toThrow(/SOCKS proxy/) + }) +}) + +describe('http-proxy invariant companion', () => { + it('reserves the package name against duplicate registration', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry) + await ctx.plugin(HttpProxyInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-http-proxy', () => {}) + }).toThrow(/already registered/) + }) +}) diff --git a/packages/net/http-proxy/tests/policy.spec.ts b/packages/net/http-proxy/tests/policy.spec.ts new file mode 100644 index 0000000000..955346b838 --- /dev/null +++ b/packages/net/http-proxy/tests/policy.spec.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { + bypassesProxy, + describeProxyPolicy, + proxyForUrl, + resolveProxyPolicy, + DIRECT_POLICY, +} from '../src/policy.ts' + +const PROXY = 'http://127.0.0.1:7897' +const OTHER = 'http://127.0.0.1:8080' + +function env(values: Record): ReturnType { + return createLaunchEnvironmentSnapshot([{ source: 'process', values }]) +} + +describe('resolveProxyPolicy', () => { + it('resolves nothing when the environment carries no proxy', () => { + const { policy, diagnostics } = resolveProxyPolicy(env({})) + expect(policy).toEqual(DIRECT_POLICY) + expect(diagnostics).toEqual([]) + }) + + it('reads both schemes and merges loopback into the bypass list', () => { + const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY, HTTPS_PROXY: OTHER, NO_PROXY: 'example.com' })) + expect(policy.httpProxy).toBe(PROXY) + expect(policy.httpsProxy).toBe(OTHER) + expect(policy.noProxy).toBe('example.com,localhost,127.0.0.1,::1,[::1]') + expect(policy.source).toBe('env') + }) + + it('prefers the lowercase name, matching undici', () => { + const { policy } = resolveProxyPolicy(env({ http_proxy: PROXY, HTTP_PROXY: OTHER })) + expect(policy.httpProxy).toBe(PROXY) + }) + + it('treats a blank lowercase value as unset instead of letting it shadow the uppercase one', () => { + const { policy } = resolveProxyPolicy(env({ http_proxy: ' ', HTTP_PROXY: PROXY })) + expect(policy.httpProxy).toBe(PROXY) + }) + + it('backs both schemes with ALL_PROXY, which neither Node nor undici reads', () => { + const { policy } = resolveProxyPolicy(env({ ALL_PROXY: PROXY })) + expect(policy.httpProxy).toBe(PROXY) + expect(policy.httpsProxy).toBe(PROXY) + }) + + it('lets a scheme-specific value outrank ALL_PROXY', () => { + const { policy } = resolveProxyPolicy(env({ ALL_PROXY: PROXY, HTTPS_PROXY: OTHER })) + expect(policy.httpProxy).toBe(PROXY) + expect(policy.httpsProxy).toBe(OTHER) + }) + + it('falls HTTPS back to the HTTP proxy last, so the dispatcher and proxyForUrl agree', () => { + const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY })) + expect(policy.httpsProxy).toBe(PROXY) + }) + + it('keeps a bypass list of * unchanged', () => { + const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY, NO_PROXY: '*' })) + expect(policy.noProxy).toBe('*') + }) + + it('does not repeat a loopback entry the user already listed', () => { + const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY, NO_PROXY: 'localhost, 127.0.0.1' })) + expect(policy.noProxy).toBe('localhost,127.0.0.1,::1,[::1]') + }) + + it('reports a SOCKS proxy instead of silently ignoring it', () => { + const { policy, diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'socks5://127.0.0.1:7890' })) + expect(policy).toEqual(DIRECT_POLICY) + expect(diagnostics).toHaveLength(1) + expect(diagnostics[0]?.kind).toBe('socks') + expect(diagnostics[0]?.message).toMatch(/SOCKS proxy, which is not supported/) + }) + + it('reports an unparseable proxy URL', () => { + const { policy, diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'not a url' })) + expect(policy).toEqual(DIRECT_POLICY) + expect(diagnostics[0]?.kind).toBe('invalid') + expect(diagnostics[0]?.origin).toBe('HTTP_PROXY') + }) + + it('reports a proxy URL whose scheme is neither http(s) nor SOCKS', () => { + const { diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'ftp://proxy.example' })) + expect(diagnostics[0]?.message).toMatch(/unsupported ftp:\/\/ scheme/) + }) + + it('lets configuration fill a gap the environment leaves', () => { + const { policy } = resolveProxyPolicy(env({}), { httpProxy: PROXY, noProxy: 'internal.example' }) + expect(policy.httpProxy).toBe(PROXY) + expect(policy.httpsProxy).toBe(PROXY) + expect(policy.noProxy).toBe('internal.example,localhost,127.0.0.1,::1,[::1]') + expect(policy.source).toBe('config') + }) + + it('lets the environment outrank configuration', () => { + const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY }), { httpProxy: OTHER }) + expect(policy.httpProxy).toBe(PROXY) + expect(policy.source).toBe('env') + }) + + it('names configuration as the origin of a rejected configured value', () => { + const { diagnostics } = resolveProxyPolicy(env({}), { httpsProxy: 'socks5://127.0.0.1:1080' }) + expect(diagnostics[0]?.origin).toBe('config.httpsProxy') + }) + + it('ignores every source under mode off', () => { + const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY }), { mode: 'off' }) + expect(policy).toEqual(DIRECT_POLICY) + }) +}) + +describe('bypassesProxy', () => { + const cases: [string, string, boolean][] = [ + ['example.com', 'http://example.com/a', true], + ['example.com', 'http://sub.example.com/a', true], + ['example.com', 'http://notexample.com/a', false], + ['.example.com', 'http://sub.example.com/a', true], + ['*.example.com', 'http://sub.example.com/a', true], + ['example.com', 'http://example.com./a', true], + ['*', 'http://anything.example/a', true], + ['example.com:8080', 'http://example.com:8080/a', true], + ['example.com:8080', 'http://example.com:9090/a', false], + ['example.com:80', 'http://example.com/a', true], + ['example.com:443', 'https://example.com/a', true], + ['EXAMPLE.com', 'http://example.COM/a', true], + ['localhost', 'http://localhost:3000/a', true], + ['127.0.0.1', 'http://127.0.0.1:7777/a', true], + ] + it.each(cases)('bypass %j against %j is %s', (noProxy, url, expected) => { + expect(bypassesProxy(noProxy, new URL(url))).toBe(expected) + }) + + it('bypasses a bare IPv6 loopback, which undici reads as host ":" port "1"', () => { + expect(bypassesProxy('::1', new URL('http://[::1]:3000/a'))).toBe(true) + }) + + it('bypasses the bracketed IPv6 form as well', () => { + expect(bypassesProxy('[::1]', new URL('http://[::1]/a'))).toBe(true) + }) + + it('honors a port on a bracketed IPv6 entry', () => { + expect(bypassesProxy('[::1]:3000', new URL('http://[::1]:3000/a'))).toBe(true) + expect(bypassesProxy('[::1]:3000', new URL('http://[::1]:4000/a'))).toBe(false) + }) + + it('does not match CIDR notation, so an OS bypass list must be rewritten as suffixes', () => { + expect(bypassesProxy('10.0.0.0/8', new URL('http://10.1.2.3/a'))).toBe(false) + }) + + it('skips blank and bracket-only entries', () => { + expect(bypassesProxy(' , , [ , .', new URL('http://example.com/a'))).toBe(false) + }) +}) + +describe('proxyForUrl', () => { + const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY, HTTPS_PROXY: OTHER, NO_PROXY: 'direct.example' })) + + it('routes http through the http proxy', () => { + expect(proxyForUrl(policy, new URL('http://example.com/'))).toBe(PROXY) + }) + + it('routes https through the https proxy', () => { + expect(proxyForUrl(policy, new URL('https://example.com/'))).toBe(OTHER) + }) + + it('returns nothing for a bypassed host', () => { + expect(proxyForUrl(policy, new URL('https://direct.example/'))).toBeUndefined() + }) + + it('returns nothing for loopback, which is always bypassed', () => { + expect(proxyForUrl(policy, new URL('http://127.0.0.1:9000/'))).toBeUndefined() + }) + + it('returns nothing for a non-http scheme', () => { + expect(proxyForUrl(policy, new URL('ws://example.com/'))).toBeUndefined() + }) + + it('returns nothing under a direct policy', () => { + expect(proxyForUrl(DIRECT_POLICY, new URL('https://example.com/'))).toBeUndefined() + }) +}) + +describe('describeProxyPolicy', () => { + it('names a direct policy', () => { + expect(describeProxyPolicy(DIRECT_POLICY)).toBe('no proxy (direct)') + }) + + it('replaces the password and keeps the username', () => { + const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: 'http://alice:s3cret@proxy.example:8080' })) + const described = describeProxyPolicy(policy) + expect(described).toContain('alice') + expect(described).toContain('***') + expect(described).not.toContain('s3cret') + }) + + it('reports a scheme left direct', () => { + expect(describeProxyPolicy({ httpsProxy: PROXY, noProxy: '', source: 'env' })).toContain('http=direct') + expect(describeProxyPolicy({ httpProxy: PROXY, noProxy: '', source: 'env' })).toContain('https=direct') + }) + + it('resolves an https-only environment without an http proxy', () => { + const { policy } = resolveProxyPolicy(env({ HTTPS_PROXY: PROXY })) + expect(policy.httpProxy).toBeUndefined() + expect(policy.httpsProxy).toBe(PROXY) + }) +}) diff --git a/packages/net/http-proxy/tsconfig.json b/packages/net/http-proxy/tsconfig.json new file mode 100644 index 0000000000..6424b28df1 --- /dev/null +++ b/packages/net/http-proxy/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../util/launch-environment" + } + ] +} diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index c912bb2398..d716adbaea 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -41,21 +41,25 @@ "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-telemetry": "workspace:^", - "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-session-telemetry": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-logger-console": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-logger-console": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", @@ -63,8 +67,6 @@ "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", - "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-subprocess-local": "workspace:^" } } diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 169f4dc9de..ed17ce93b8 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -32,6 +32,7 @@ import { type BatchLogRecordProcessorOptions, } from '@opentelemetry/sdk-logs' import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import { createNodeHttpAgent } from '@deepseek-ai/dsh-http-proxy' import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base' import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' import { resourceFromAttributes } from '@opentelemetry/resources' @@ -212,7 +213,15 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { // ignore the rest. App identity travels in the Resource // (service.name/version); the transport-level user-agent is the // SDK's own, per the axiom. - exporter: new OTLPLogExporter(config.exporter), + // + // The one added default is the agent. On Node this exporter posts through `node:http`, + // which undici's global dispatcher does not reach, so telemetry would be the one egress + // that ignores a configured proxy. A composition supplying its own `httpAgentOptions` + // keeps it. + exporter: new OTLPLogExporter({ + httpAgentOptions: (protocol: string) => createNodeHttpAgent(protocol, { keepAlive: true }), + ...config.exporter, + }), }), ], }) diff --git a/packages/session/session-telemetry-otel/tests/egress.spec.ts b/packages/session/session-telemetry-otel/tests/egress.spec.ts new file mode 100644 index 0000000000..6519e461dd --- /dev/null +++ b/packages/session/session-telemetry-otel/tests/egress.spec.ts @@ -0,0 +1,71 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import OpenTelemetrySessionBackend, { SessionTelemetryMode } from '../src/index.ts' + +let home: string +let previousHome: string | undefined +beforeAll(() => { + home = mkdtempSync(join(tmpdir(), 'dsh-otel-egress-')) + previousHome = process.env.DSH_HOME + process.env.DSH_HOME = home +}) +afterAll(() => { + if (previousHome === undefined) delete process.env.DSH_HOME + else process.env.DSH_HOME = previousHome + rmSync(home, { recursive: true, force: true }) +}) + +/** Mount the shipping backend against an unresolvable collector and let it try to export. */ +async function exportThroughBackend(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { + mode: SessionTelemetryMode.FULL, + exporter: { url: 'http://otel-probe.invalid/v1/logs' }, + }) + const session = ctx.sessions.create(SessionId('egress'), { meta: { cwd: '/tmp/e' } }) + session.append('turn/start', { turn: 1 }) + ctx.sessionTelemetry.emit({ channel: 'ledger', time: Date.now(), severity: 'info', event: { type: 'probe' } } as never) + await fiber.dispose() +} + +describe('session-telemetry-otel egress', () => { + it('exports through the proxy', async () => { + expect((await observe(exportThroughBackend)).join('|')).toContain('otel-probe.invalid') + }) +}) diff --git a/packages/session/session-telemetry-otel/tsconfig.json b/packages/session/session-telemetry-otel/tsconfig.json index 65acdeb976..921c563277 100644 --- a/packages/session/session-telemetry-otel/tsconfig.json +++ b/packages/session/session-telemetry-otel/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 1a10def591..55621b268b 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -32,11 +32,13 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" } } diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index a7413159f9..34c08e9875 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -9,6 +9,7 @@ */ import { Context, Service } from '@deepseek-ai/cordis' +import { childProxyEnv } from '@deepseek-ai/dsh-http-proxy' import { DSH_ENV_PREFIX } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' @@ -55,6 +56,9 @@ export const SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i * deliberate lowercase `dsh_*` names on POSIX are implausible. Exported as a plain function so spawners * that cannot route through the service (node-pty backends, SDK-managed * transports) share the one scrub definition. + * + * When a proxy is active the result also carries the resolved proxy names and the flag a child Node + * needs to honor them, so a child inherits the same routing as its parent. * @returns a fresh environment object safe to hand to a child spawn. */ export function scrubbedParentEnv(): Record { @@ -62,7 +66,9 @@ export function scrubbedParentEnv(): Record { for (const [key, value] of Object.entries(process.env)) { if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value } - return env + // A child Node ignores the inherited proxy variables unless the flag this adds is set, so an MCP + // stdio server or subagent CLI would connect directly while its parent proxies. + return { ...env, ...childProxyEnv() } } declare module '@deepseek-ai/cordis' { diff --git a/packages/subprocess/subprocess/tests/egress.spec.ts b/packages/subprocess/subprocess/tests/egress.spec.ts new file mode 100644 index 0000000000..ee30bc545a --- /dev/null +++ b/packages/subprocess/subprocess/tests/egress.spec.ts @@ -0,0 +1,57 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { spawn } from 'node:child_process' +import { scrubbedParentEnv } from '../src/index.ts' + +/** Run a child Node that fetches, using exactly the environment every harness spawner builds. */ +function childFetch(target: string, env: Record): Promise { + return new Promise((resolve) => { + const child = spawn(process.execPath, ['-e', `fetch(${JSON.stringify(target)}).then(r=>r.text()).then(t=>console.log(t)).catch(e=>console.log('ERR'+String(e.cause?.code)))`], + { env, stdio: ['ignore', 'pipe', 'ignore'] }) + let out = '' + child.stdout.on('data', (c: Buffer) => { out += c.toString() }) + child.on('close', () => { resolve(out.trim()) }) + }) +} + +describe('child process egress', () => { + it('a child Node honors the parent policy through scrubbedParentEnv', async () => { + let childEnv: Record = {} + const observed = await observe(async () => { + childEnv = scrubbedParentEnv() + await childFetch('http://child-probe.invalid/x', childEnv) + }) + expect(childEnv.NODE_USE_ENV_PROXY).toBe('1') + expect(observed.join('|')).toContain('child-probe.invalid') + }) +}) diff --git a/packages/subprocess/subprocess/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json index bb46910c07..d7a22325fc 100644 --- a/packages/subprocess/subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index dc0697c111..d274adfacb 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -33,6 +33,7 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^" @@ -44,6 +45,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^" diff --git a/packages/web/web-fetch-http/src/network.ts b/packages/web/web-fetch-http/src/network.ts index 102ffe27a4..5c69e8e013 100644 --- a/packages/web/web-fetch-http/src/network.ts +++ b/packages/web/web-fetch-http/src/network.ts @@ -9,7 +9,8 @@ import { lookup as systemLookup } from 'node:dns/promises' import type { LookupAddress, LookupOptions } from 'node:dns' import { isIP } from 'node:net' -import type { Response } from 'undici' +import type { Agent, Response } from 'undici' +import { createDispatcher } from '@deepseek-ai/dsh-http-proxy' import ipaddr from 'ipaddr.js' import { WebError } from '@deepseek-ai/dsh-web' @@ -173,14 +174,56 @@ export async function requestPinned( headers: Record, signal: AbortSignal, ): Promise { - // Keep the Node-only transport out of browser-worker startup. The preview - // can load the provider and fail loud at its DNS stub without evaluating - // Undici; a real request on Node resolves this maintained dependency here. - const { Agent, fetch } = await import('undici') - const dispatcher = new Agent({ + return await requestWith(url, headers, signal, { autoSelectFamily: true, connect: { lookup: createPinnedLookup(addresses) }, }) +} + +/** + * Fetch through the active proxy, letting it resolve the origin. + * + * No address set is pinned because none exists to pin: the proxy performs the lookup, and a + * connection pinned to a locally resolved address would reach the origin directly and defeat the + * proxy. Configuring a proxy therefore delegates destination selection to it; the URL-level policy + * in `policy.ts` still applies to every hop. + * + * @param url - validated HTTP(S) URL the active policy routes through a proxy. + * @param headers - request headers. + * @param signal - request and body-read cancellation signal. + * @returns a response plus the dispatcher disposer its consumer must call. + */ +export async function requestProxied( + url: URL, + headers: Record, + signal: AbortSignal, +): Promise { + return await requestWith(url, headers, signal, {}) +} + +/** + * Issue one request on a policy-aware dispatcher the caller then owns. + * + * The dispatcher comes from `dsh-http-proxy` rather than a bare `new Agent`, which would bypass the + * global dispatcher and with it the proxy — the defect this package had before proxy support existed. + * + * @param url - validated HTTP(S) URL. + * @param headers - request headers. + * @param signal - request and body-read cancellation signal. + * @param options - agent options applied to whichever agent the policy selects. + * @returns a response plus the dispatcher disposer its consumer must call. + */ +async function requestWith( + url: URL, + headers: Record, + signal: AbortSignal, + options: Agent.Options, +): Promise { + // Keep the Node-only transport out of browser-worker startup. The preview + // can load the provider and fail loud at its DNS stub without evaluating + // Undici; a real request on Node resolves this maintained dependency here. + const { fetch } = await import('undici') + const dispatcher = await createDispatcher(url, options) try { const response = await fetch(url, { method: 'GET', redirect: 'manual', headers, signal, dispatcher }) return { response, close: async () => { await dispatcher.close() } } @@ -194,6 +237,7 @@ export async function requestPinned( export const publicHttpNetwork = { resolve: resolvePublicAddresses, request: requestPinned, + requestProxied, } type LookupCallback = ( diff --git a/packages/web/web-fetch-http/src/provider.ts b/packages/web/web-fetch-http/src/provider.ts index 8f783d4ed7..223489321b 100644 --- a/packages/web/web-fetch-http/src/provider.ts +++ b/packages/web/web-fetch-http/src/provider.ts @@ -10,6 +10,7 @@ import { WebError } from '@deepseek-ai/dsh-web' import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } from '@deepseek-ai/dsh-web' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { Response } from 'undici' +import { currentProxyPolicy, proxyForUrl, DIRECT_POLICY } from '@deepseek-ai/dsh-http-proxy' import { publicHttpNetwork } from './network.ts' import type { PublicAddress } from './network.ts' import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' @@ -114,12 +115,20 @@ export class HttpFetchProvider implements WebFetchProvider { } private async requestOnce(url: URL, signal: AbortSignal) { + const headers = { + 'user-agent': this.limits.userAgent, + 'accept': 'text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8', + } try { + // A proxied hop skips public-address resolution and pinning: the proxy performs the origin's + // DNS, so there is no local address to validate, and pinning one would connect directly and + // bypass the proxy. A hop the policy bypasses — every loopback and every `NO_PROXY` entry — + // still takes the resolved-and-pinned path unchanged. + if (proxyForUrl(currentProxyPolicy() ?? DIRECT_POLICY, url) !== undefined) { + return await publicHttpNetwork.requestProxied(url, headers, signal) + } const addresses = await this.resolveAddresses(url.hostname, signal) - return await publicHttpNetwork.request(url, addresses, { - 'user-agent': this.limits.userAgent, - 'accept': 'text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8', - }, signal) + return await publicHttpNetwork.request(url, addresses, headers, signal) } catch (error: unknown) { if (error instanceof WebError) throw error throw translateAbortOrNetwork(error, signal) diff --git a/packages/web/web-fetch-http/tests/proxy.spec.ts b/packages/web/web-fetch-http/tests/proxy.spec.ts new file mode 100644 index 0000000000..23fd372c55 --- /dev/null +++ b/packages/web/web-fetch-http/tests/proxy.spec.ts @@ -0,0 +1,119 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' +import { HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http' +import type { HttpFetchLimits } from '@deepseek-ai/dsh-web-fetch-http' +import { publicHttpNetwork } from '../src/network.ts' + +const limits: HttpFetchLimits = { + maxResponseBytes: 5_000_000, + maxBodyChars: 100_000, + timeoutMs: 5_000, + maxRedirects: 5, + userAgent: 'test-agent/1.0', +} + +/** Absolute-form targets the fake proxy saw; a populated entry proves the hop was tunnelled. */ +let proxied: string[] +let proxy: Server +let origin: Server +let proxyUrl: string +let originUrl: string +let disposeProxy: (() => Promise) | undefined + +function listen(server: Server): Promise { + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { resolve(server.address() as AddressInfo) }) + }) +} + +function respond(_request: IncomingMessage, response: ServerResponse, body: string): void { + response.writeHead(200, { 'content-type': 'text/plain' }) + response.end(body) +} + +beforeEach(async () => { + proxied = [] + proxy = createServer((request, response) => { + proxied.push(request.url ?? '') + respond(request, response, 'via-proxy') + }) + origin = createServer((request, response) => { respond(request, response, 'direct') }) + const [proxyAddress, originAddress] = await Promise.all([listen(proxy), listen(origin)]) + proxyUrl = `http://127.0.0.1:${String(proxyAddress.port)}` + originUrl = `http://127.0.0.1:${String(originAddress.port)}/page` +}) + +afterEach(async () => { + await disposeProxy?.() + disposeProxy = undefined + vi.restoreAllMocks() + await Promise.all([ + new Promise((resolve) => { proxy.close(() => { resolve() }) }), + new Promise((resolve) => { origin.close(() => { resolve() }) }), + ]) +}) + +/** A policy proxying everything, since a resolved policy always bypasses the loopback used here. */ +function policy(noProxy = ''): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy, source: 'env' } +} + +describe('fetching through a proxy', () => { + it('tunnels the request and never resolves a public address for it', async () => { + const resolve = vi.spyOn(publicHttpNetwork, 'resolve') + disposeProxy = await installGlobalProxy(policy()) + + const result = await new HttpFetchProvider(limits).fetch({ url: originUrl }) + + expect(result.body.content).toBe('via-proxy') + expect(proxied).toEqual([originUrl]) + // Through a proxy the origin's DNS happens proxy-side, so the resolver that rejects non-public + // destinations is not consulted at all. + expect(resolve).not.toHaveBeenCalled() + }) + + it('keeps resolving and pinning a hop the bypass list covers', async () => { + const resolve = vi.spyOn(publicHttpNetwork, 'resolve') + .mockResolvedValue([{ address: '127.0.0.1', family: 4 }]) + disposeProxy = await installGlobalProxy(policy('127.0.0.1')) + + const result = await new HttpFetchProvider(limits).fetch({ url: originUrl }) + + expect(result.body.content).toBe('direct') + expect(proxied).toEqual([]) + expect(resolve).toHaveBeenCalledOnce() + }) + + it('resolves and pins when no proxy is installed', async () => { + const resolve = vi.spyOn(publicHttpNetwork, 'resolve') + .mockResolvedValue([{ address: '127.0.0.1', family: 4 }]) + + const result = await new HttpFetchProvider(limits).fetch({ url: originUrl }) + + expect(result.body.content).toBe('direct') + expect(resolve).toHaveBeenCalledOnce() + }) + + it('still refuses a cross-origin redirect on the proxied path', async () => { + proxy.removeAllListeners('request') + proxy.on('request', (request, response) => { + proxied.push(request.url ?? '') + response.writeHead(302, { location: 'http://elsewhere.example/next' }) + response.end() + }) + disposeProxy = await installGlobalProxy(policy()) + + await expect(new HttpFetchProvider(limits).fetch({ url: originUrl })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) + }) + + it('still refuses a URL the transport policy rejects before any hop', async () => { + disposeProxy = await installGlobalProxy(policy()) + + await expect(new HttpFetchProvider(limits).fetch({ url: 'ftp://example.com/x' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) + expect(proxied).toEqual([]) + }) +}) diff --git a/packages/web/web-fetch-http/tsconfig.json b/packages/web/web-fetch-http/tsconfig.json index a6599c60cd..6d97b71578 100644 --- a/packages/web/web-fetch-http/tsconfig.json +++ b/packages/web/web-fetch-http/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 1f6ea441c9..2bce25a560 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -45,14 +45,15 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^" } } diff --git a/packages/web/web-search-deepseek/tests/egress.spec.ts b/packages/web/web-search-deepseek/tests/egress.spec.ts new file mode 100644 index 0000000000..3be54fe61d --- /dev/null +++ b/packages/web/web-search-deepseek/tests/egress.spec.ts @@ -0,0 +1,39 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { DeepSeekSearchProvider } from '../src/provider.ts' +describe('deepseek search egress', () => { + it('goes through the proxy', async () => { + const p = new DeepSeekSearchProvider(() => ({ apiKey: 'probe', baseURL: 'http://dsk-probe.invalid', model: 'm', apiVersion: '2023-06-01', maxTokens: 16, maxUses: 1 })) + expect(await observe(() => p.search({ query: 'probe' }))).toEqual(['REQ http://dsk-probe.invalid/messages']) + }) +}) diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index a2e33ca5c5..0298dad5be 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index a189645134..b4dfe0b4fe 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -41,9 +41,10 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^" } } diff --git a/packages/web/web-search-exa/tests/egress.spec.ts b/packages/web/web-search-exa/tests/egress.spec.ts new file mode 100644 index 0000000000..44ef88aaba --- /dev/null +++ b/packages/web/web-search-exa/tests/egress.spec.ts @@ -0,0 +1,39 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { ExaSearchProvider } from '../src/provider.ts' +describe('exa egress', () => { + it('goes through the proxy', async () => { + const p = new ExaSearchProvider({ apiKey: 'probe', baseURL: 'http://exa-probe.invalid', searchType: 'auto', highlightsPerResult: 1 }) + expect(await observe(() => p.search({ query: 'probe' }))).toEqual(['REQ http://exa-probe.invalid/search']) + }) +}) diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json index b1cb44f9cc..e3274421f5 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index ae3c485ef0..8c60f7110f 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -41,9 +41,10 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^" } } diff --git a/packages/web/web-search-perplexity/tests/egress.spec.ts b/packages/web/web-search-perplexity/tests/egress.spec.ts new file mode 100644 index 0000000000..2a698d8142 --- /dev/null +++ b/packages/web/web-search-perplexity/tests/egress.spec.ts @@ -0,0 +1,39 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { PerplexitySearchProvider } from '../src/provider.ts' +describe('perplexity egress', () => { + it('goes through the proxy', async () => { + const p = new PerplexitySearchProvider({ apiKey: 'probe', baseURL: 'http://ppx-probe.invalid', model: 'm', maxTokens: 16 }) + expect(await observe(() => p.search({ query: 'probe' }))).toEqual(['REQ http://ppx-probe.invalid/chat/completions']) + }) +}) diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json index b1cb44f9cc..e3274421f5 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 328ae143b7..3f1b3743b4 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "tsx": "^4.19.2", - "@deepseek-ai/dsh-session-projection": "workspace:^" + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" } } diff --git a/packages/workflow/workflow-worker-thread/src/host.ts b/packages/workflow/workflow-worker-thread/src/host.ts index 394b65ca8a..22623f5e54 100644 --- a/packages/workflow/workflow-worker-thread/src/host.ts +++ b/packages/workflow/workflow-worker-thread/src/host.ts @@ -8,6 +8,7 @@ import { tmpdir } from 'node:os' import { Worker } from 'node:worker_threads' +import { childProxyEnv } from '@deepseek-ai/dsh-http-proxy' import type { WorkerOptions } from 'node:worker_threads' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' @@ -30,7 +31,9 @@ interface ChildRecord { } /** - * The scrubbed worker environment: no ambient credentials, no loader flags. + * The scrubbed worker environment: no ambient credentials, no loader flags, plus the active proxy + * policy — a worker thread does not inherit the host's global dispatcher, so this is the only way + * its requests reach the same proxy the host uses. * Windows derives `os.tmpdir()` from `TMP`/`TEMP` and falls back to the * literal relative path `undefined\temp` when the environment is empty, so * tsx's transform cache would land in a cwd-relative `undefined/temp` @@ -46,7 +49,11 @@ export function workerSpawnEnv( platform: NodeJS.Platform = process.platform, tsconfigPath?: string, ): NodeJS.ProcessEnv { - const env: NodeJS.ProcessEnv = {} + // A worker thread gets its own globalThis and therefore does NOT inherit the host's undici global + // dispatcher, so a workflow that fetches would connect directly while its host proxies. This + // near-empty environment is the only channel it has: the proxy names plus Node's own opt-in flag + // reach the worker's pre-execution setup, which runs per thread. + const env: NodeJS.ProcessEnv = { ...childProxyEnv() } if (platform === 'win32') { const tmp = tmpdir() env.TMP = tmp diff --git a/packages/workflow/workflow-worker-thread/tests/egress.spec.ts b/packages/workflow/workflow-worker-thread/tests/egress.spec.ts new file mode 100644 index 0000000000..e36354d51b --- /dev/null +++ b/packages/workflow/workflow-worker-thread/tests/egress.spec.ts @@ -0,0 +1,51 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { Worker } from 'node:worker_threads' +import { once } from 'node:events' +import { workerSpawnEnv } from '../src/host.ts' + +describe('worker thread egress', () => { + it('a worker honors the host policy through workerSpawnEnv', async () => { + const observed = await observe(async () => { + const worker = new Worker( + `import { parentPort, workerData } from 'node:worker_threads' + let out; try { out = await (await fetch(workerData.u)).text() } catch (e) { out = 'ERR' + String(e.cause?.code) } + parentPort.postMessage(out)`, + { eval: true, workerData: { u: 'http://worker-probe.invalid/x' }, env: workerSpawnEnv(), execArgv: [] }, + ) + await once(worker, 'message') + await worker.terminate() + }) + expect(observed.join('|')).toContain('worker-probe.invalid') + }) +}) diff --git a/packages/workflow/workflow-worker-thread/tsconfig.json b/packages/workflow/workflow-worker-thread/tsconfig.json index 4111e22af8..17b9cf245f 100644 --- a/packages/workflow/workflow-worker-thread/tsconfig.json +++ b/packages/workflow/workflow-worker-thread/tsconfig.json @@ -40,6 +40,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f790a3c1a9..7193f6c0d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -198,6 +198,9 @@ importers: '@deepseek-ai/dsh-hooks-codex': specifier: workspace:^ version: link:../../packages/hooks/hooks-codex + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../packages/net/http-proxy '@deepseek-ai/dsh-jobs-local': specifier: workspace:^ version: link:../../packages/jobs/jobs-local @@ -4583,6 +4586,9 @@ importers: '@deepseek-ai/dsh-fs-e2b': specifier: workspace:^ version: link:../fs-e2b + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6486,6 +6492,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6762,6 +6771,9 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:^ version: link:../../attachment/attachment-local + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6787,6 +6799,25 @@ importers: specifier: ^2026.7.4 version: 2026.7.10(zod@4.4.3) + packages/net/http-proxy: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + undici: + specifier: ^8.10.0 + version: 8.10.0 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment + packages/plan/plan-mode: dependencies: zod: @@ -7622,6 +7653,9 @@ importers: '@deepseek-ai/dsh-command-feedback': specifier: workspace:^ version: link:../../feedback/command-feedback + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9155,6 +9189,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9835,6 +9872,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9863,6 +9903,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9888,6 +9931,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9907,6 +9953,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -10130,6 +10179,9 @@ importers: '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -10317,6 +10369,9 @@ importers: '@deepseek-ai/dsh-hooks-codex': specifier: workspace:^ version: link:../../packages/hooks/hooks-codex + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../packages/net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../packages/runtime-diagnostics/invariants diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 61dc113188..49cebef557 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -5,6 +5,7 @@ "private": true, "type": "module", "dependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -13,14 +14,15 @@ "@deepseek-ai/dsh": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-agent-tool-presentation": "workspace:^", + "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", - "@deepseek-ai/dsh-shell": "workspace:^", - "@deepseek-ai/dsh-shell-env": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-code-runtime": "workspace:^", @@ -32,45 +34,44 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", - "@deepseek-ai/dsh-authorization": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-round-driver": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", "@deepseek-ai/dsh-hooks-claude-code": "workspace:^", "@deepseek-ai/dsh-hooks-codex": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", - "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", - "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-permission-presets": "workspace:^", - "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-persona": "workspace:^", - "@deepseek-ai/dsh-pwsh-local": "workspace:^", - "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", - "@deepseek-ai/dsh-terminal": "workspace:^", - "@deepseek-ai/dsh-terminal-bash": "workspace:^", - "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-output-retention": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-persona": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", @@ -81,6 +82,8 @@ "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-shell": "workspace:^", + "@deepseek-ai/dsh-shell-env": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", @@ -92,32 +95,32 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", - "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-pwsh": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", - "@deepseek-ai/dsh-tool-jobs": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", @@ -127,8 +130,6 @@ "@deepseek-ai/dsh-web-search-perplexity": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", - "@deepseek-ai/dsh-agent-instructions": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index f3052d6c25..386fc0cd33 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -286,6 +286,7 @@ function ciSharedStaticGates(): Gate[] { }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), + pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }), ] } @@ -680,6 +681,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { }), pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }), pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }), + pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }), ] } diff --git a/scripts/verify-no-bare-dispatcher.spec.ts b/scripts/verify-no-bare-dispatcher.spec.ts new file mode 100644 index 0000000000..eebbc533d7 --- /dev/null +++ b/scripts/verify-no-bare-dispatcher.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import { findDispatcherViolations, scanRepository, DISPATCHER_OWNER } from './verify-no-bare-dispatcher.ts' + +const FILE = 'packages/web/web-fetch-http/src/network.ts' + +function reasons(source: string, file = FILE): string[] { + return findDispatcherViolations(file, source).map(violation => violation.what) +} + +describe('bare dispatcher check', () => { + it('rejects the shape that silently bypassed the proxy before this rule existed', () => { + expect(reasons(` + const dispatcher = new Agent({ connect: { lookup } }) + const response = await fetch(url, { dispatcher }) + `)).toEqual(['constructs an undici agent']) + }) + + it('rejects an explicit dispatcher option however the agent was obtained', () => { + expect(reasons(" const response = await fetch(url, { method: 'GET', dispatcher: pooled })")) + .toEqual(['passes an explicit `dispatcher`']) + }) + + it('rejects a namespaced construction', () => { + expect(reasons(' const agent = new undici.ProxyAgent(uri)')).toEqual(['constructs an undici agent']) + }) + + it('reports the offending line number and text', () => { + expect(findDispatcherViolations(FILE, 'const a = 1\nconst b = new Agent({})')).toEqual([ + { file: FILE, line: 2, what: 'constructs an undici agent', text: 'const b = new Agent({})' }, + ]) + }) + + it('accepts the sanctioned factory', () => { + expect(reasons(' const dispatcher = await createDispatcher(url, options)')).toEqual([]) + }) + + it('accepts an annotated exemption', () => { + expect(reasons(' const agent = new Agent({}) // proxy-exempt: loopback transport for the local test server')) + .toEqual([]) + }) + + it('exempts the package that owns dispatcher construction', () => { + expect(reasons('const agent = new EnvHttpProxyAgent()', `${DISPATCHER_OWNER}src/install.ts`)).toEqual([]) + }) + + it('normalizes native separators before exempting the owning package', () => { + expect(reasons('const agent = new Agent({})', DISPATCHER_OWNER.replaceAll('/', '\\') + 'src\\install.ts')).toEqual([]) + }) + + it('passes on the current tree', () => { + expect(scanRepository()).toEqual([]) + }) +}) diff --git a/scripts/verify-no-bare-dispatcher.ts b/scripts/verify-no-bare-dispatcher.ts new file mode 100644 index 0000000000..422e37daf2 --- /dev/null +++ b/scripts/verify-no-bare-dispatcher.ts @@ -0,0 +1,91 @@ +/** + * Verify that no package builds its own undici agent or hands `fetch` an explicit dispatcher. + * + * Node's built-in `fetch` routes through undici's global dispatcher, which `@deepseek-ai/dsh-http-proxy` + * installs at launch. An explicitly supplied `dispatcher` overrides that global one, so a call site + * that constructs `new Agent(...)` itself connects directly no matter what proxy the user configured + * — the exact defect `web-fetch-http` carried before proxy support existed, where its DNS-pinning + * agent silently bypassed every proxy. + * + * `createDispatcher()` from that package is the sanctioned way to get agent options AND the policy. + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') + +/** The package that owns dispatcher construction; its own agents are the implementation. */ +export const DISPATCHER_OWNER = 'packages/net/http-proxy/' + +/** A line carrying this marker states why it is exempt and is left alone. */ +export const ALLOW_MARKER = 'proxy-exempt:' + +/** Constructing an undici agent, or naming a `dispatcher` option, outside the owning package. */ +const PATTERNS: readonly { readonly probe: RegExp; readonly what: string }[] = [ + { probe: /\bnew\s+(?:undici\.)?(?:Agent|ProxyAgent|EnvHttpProxyAgent)\s*\(/, what: 'constructs an undici agent' }, + { probe: /\bdispatcher\s*:/, what: 'passes an explicit `dispatcher`' }, +] + +/** One source line that would bypass the configured proxy. */ +export interface DispatcherViolation { + /** Repository-relative path, in POSIX separators. */ + readonly file: string + /** One-based line number. */ + readonly line: number + /** Which rule the line broke. */ + readonly what: string + /** The offending line, trimmed. */ + readonly text: string +} + +/** + * Find every bare-dispatcher line in one source file. + * + * @param file - repository-relative path, used to exempt the owning package and to report location. + * @param sourceText - the file's contents. + * @returns one violation per offending line, in file order. + */ +export function findDispatcherViolations(file: string, sourceText: string): DispatcherViolation[] { + const posix = file.replaceAll('\\', '/') + if (posix.startsWith(DISPATCHER_OWNER)) return [] + const violations: DispatcherViolation[] = [] + sourceText.split('\n').forEach((text, index) => { + if (text.includes(ALLOW_MARKER)) return + for (const { probe, what } of PATTERNS) { + if (probe.test(text)) violations.push({ file: posix, line: index + 1, what, text: text.trim() }) + } + }) + return violations +} + +/** + * Scan every package and app source file in the repository. + * + * @returns every violation found, grouped by the order the files were scanned. + */ +export function scanRepository(): DispatcherViolation[] { + const files = [ + ...globSync('packages/*/*/src/**/*.ts', { cwd: root }), + ...globSync('apps/*/src/**/*.ts', { cwd: root }), + ] + return files.flatMap(file => findDispatcherViolations(file, readFileSync(resolve(root, file), 'utf8'))) +} + +function main(): void { + const violations = scanRepository() + if (violations.length === 0) { + console.log(`verify-no-bare-dispatcher: no bare dispatcher outside ${DISPATCHER_OWNER}.`) + return + } + console.error('verify-no-bare-dispatcher: a dispatcher built outside @deepseek-ai/dsh-http-proxy bypasses the configured proxy.\n') + for (const violation of violations) { + console.error(` ${violation.file}:${String(violation.line)} ${violation.what}`) + console.error(` ${violation.text}`) + } + console.error('\nUse `createDispatcher(url, options)` from @deepseek-ai/dsh-http-proxy, or annotate the line') + console.error(`with a \`${ALLOW_MARKER} \` comment when the request must genuinely ignore the proxy.`) + process.exit(1) +} + +if (import.meta.filename === resolve(process.argv[1] ?? '')) main() diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 37a488c021..ca6d8c50eb 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -62,6 +62,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, 'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' }, 'packages/e2b/e2b': { kind: 'none', reason: 'The shared remote-runtime owner registers no model context; provider adapters and consumers own rendered effects.' }, + 'packages/net/http-proxy': { kind: 'none', reason: 'Transport policy only: it changes how bytes reach the network and registers no prompt, schema, or result text.' }, 'packages/client/hmr': { kind: 'none', reason: 'Browser-side UI plugin layer; registers nothing model-facing.' }, 'packages/client/modules': { kind: 'none', reason: 'Browser-side module-loading kernel machinery; registers nothing model-facing.' }, 'packages/test-support/client-runtime': { kind: 'none', reason: 'Browser-side test infrastructure (jsdom bench); registers nothing model-facing.' }, diff --git a/scripts/verify-subsystem-pages.ts b/scripts/verify-subsystem-pages.ts index 2906616af1..817d66233c 100644 --- a/scripts/verify-subsystem-pages.ts +++ b/scripts/verify-subsystem-pages.ts @@ -20,6 +20,7 @@ export const GROUPS_WITHOUT_SUBSYSTEM_PAGE: Readonly> = { bundle: 'Composition patch carriers whose mounted packages own all runtime contracts.', examples: 'Non-product demonstration compositions whose mounted packages own all runtime contracts.', hooks: 'External hook-protocol bridges over existing interception points, not a new Harness service.', + net: 'Process-wide transport policy with no service, no seam, and no runtime vocabulary of its own; the user guide and the one package README own it.', sdk: 'Out-of-process protocol and client packages whose package READMEs own the SDK contracts.', util: 'Low-level primitives whose business semantics remain with their consuming subsystems.', } diff --git a/tsconfig.base.json b/tsconfig.base.json index e18c1b6a26..112a2a1080 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -337,6 +337,8 @@ "@deepseek-ai/dsh-hooks-claude-code/invariant": ["./packages/hooks/hooks-claude-code/src/invariant.ts"], "@deepseek-ai/dsh-hooks-codex": ["./packages/hooks/hooks-codex/src"], "@deepseek-ai/dsh-hooks-codex/invariant": ["./packages/hooks/hooks-codex/src/invariant.ts"], + "@deepseek-ai/dsh-http-proxy": ["./packages/net/http-proxy/src"], + "@deepseek-ai/dsh-http-proxy/invariant": ["./packages/net/http-proxy/src/invariant.ts"], "@deepseek-ai/dsh-invariants/invariant": ["./packages/runtime-diagnostics/invariants/src/invariant.ts"], "@deepseek-ai/dsh-jobs": ["./packages/jobs/jobs/src"], "@deepseek-ai/dsh-jobs/invariant": ["./packages/jobs/jobs/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 1849fce913..45cedf3394 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -127,6 +127,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/net/http-proxy" }, { "path": "./packages/util/launch-environment" }, { "path": "./packages/util/native-command" }, { "path": "./packages/util/home-paths" }, diff --git a/website/docs.ts b/website/docs.ts index b4d575a576..3a54d64cc3 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -130,6 +130,14 @@ const homeAndGuide = pairedPages([ section: { root: '入门', en: 'Guide' }, order: 2, }, + { + source: 'docs/user/guide/network-proxy.md', + route: 'guide/network-proxy.md', + label: { root: '网络代理', en: 'Network proxy' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, + order: 3, + }, { source: 'docs/user/guide/python-sdk.md', route: 'guide/python-sdk.md', From ec82e3e3eeb8646366373adf5918ff7a922c1d13 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:48:27 +0800 Subject: [PATCH 002/131] test(net): assert the child and worker proxy seam by Node version NODE_USE_ENV_PROXY reaches Node 24.0+ and 22.21+, while engines admits 22.19. Assert the direct connection on an older runtime instead of only the proxied one, so the seam is executable rather than prose. --- .../subprocess/subprocess/tests/egress.spec.ts | 16 +++++++++++++++- .../workflow-worker-thread/tests/egress.spec.ts | 15 ++++++++++++++- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/subprocess/subprocess/tests/egress.spec.ts b/packages/subprocess/subprocess/tests/egress.spec.ts index ee30bc545a..c7395a377e 100644 --- a/packages/subprocess/subprocess/tests/egress.spec.ts +++ b/packages/subprocess/subprocess/tests/egress.spec.ts @@ -44,6 +44,17 @@ function childFetch(target: string, env: Record): Promise= 24 || (major === 22 && minor >= 21) +} + describe('child process egress', () => { it('a child Node honors the parent policy through scrubbedParentEnv', async () => { let childEnv: Record = {} @@ -52,6 +63,9 @@ describe('child process egress', () => { await childFetch('http://child-probe.invalid/x', childEnv) }) expect(childEnv.NODE_USE_ENV_PROXY).toBe('1') - expect(observed.join('|')).toContain('child-probe.invalid') + // The flag is what a child Node acts on; an older runtime ignores it and stays direct, which is + // the documented seam rather than a defect. + if (supportsEnvProxy()) expect(observed.join('|')).toContain('child-probe.invalid') + else expect(observed).toEqual([]) }) }) diff --git a/packages/workflow/workflow-worker-thread/tests/egress.spec.ts b/packages/workflow/workflow-worker-thread/tests/egress.spec.ts index e36354d51b..05b1cd9cb0 100644 --- a/packages/workflow/workflow-worker-thread/tests/egress.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/egress.spec.ts @@ -34,6 +34,17 @@ import { Worker } from 'node:worker_threads' import { once } from 'node:events' import { workerSpawnEnv } from '../src/host.ts' + +/** + * Whether this runtime honors `NODE_USE_ENV_PROXY`, which is how a separate Node execution context + * receives the policy. Added in Node 24.0 and backported to 22.21; the engines range admits 22.19 + * and 22.20, where such a context stays direct. + */ +function supportsEnvProxy(): boolean { + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number) + return major >= 24 || (major === 22 && minor >= 21) +} + describe('worker thread egress', () => { it('a worker honors the host policy through workerSpawnEnv', async () => { const observed = await observe(async () => { @@ -46,6 +57,8 @@ describe('worker thread egress', () => { await once(worker, 'message') await worker.terminate() }) - expect(observed.join('|')).toContain('worker-probe.invalid') + // Same seam as a spawned child: the worker acts on the flag its environment carries. + if (supportsEnvProxy()) expect(observed.join('|')).toContain('worker-probe.invalid') + else expect(observed).toEqual([]) }) }) From 35bc2d1d45c3d5d3cc488bbe905be91eb534c167 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:57:40 +0800 Subject: [PATCH 003/131] fix(webworker): register a node:https placeholder for the proxy agent factory The VFS packer sweeps module requests statically, so dsh-http-proxy's agent factory made the preview image unpackable: it names node:https for the SDKs that post through Node's core HTTP modules, a path the worker never takes. Mock it the way node:net is mocked rather than hiding the request. --- .../webworker-runtime/src/module-proxies.ts | 1 + .../src/node/builtin_modules/mock/https.ts | 49 +++++++++++++++++++ .../tests/node/node-stubs.spec.ts | 16 ++++++ 3 files changed, 66 insertions(+) create mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/mock/https.ts diff --git a/packages/experimental/webworker-runtime/src/module-proxies.ts b/packages/experimental/webworker-runtime/src/module-proxies.ts index 3bb59ef366..3bd28f7430 100644 --- a/packages/experimental/webworker-runtime/src/module-proxies.ts +++ b/packages/experimental/webworker-runtime/src/module-proxies.ts @@ -41,6 +41,7 @@ export const MODULE_PROXIES: Record = { // `process` are absent on purpose — the worker host installs that global // (`./globals/process.ts`). 'node:http': './node/builtin_modules/implemented/http.ts', + 'node:https': './node/builtin_modules/mock/https.ts', // Sync-stack AsyncLocalStorage semantics. 'node:async_hooks': './node/builtin_modules/implemented/async_hooks.ts', // Real implementations over browser primitives. diff --git a/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/https.ts b/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/https.ts new file mode 100644 index 0000000000..549c5390fb --- /dev/null +++ b/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/https.ts @@ -0,0 +1,49 @@ +/** + * `node:https` for the worker. Nothing here dials TLS: the only module that reaches for this one is + * `dsh-http-proxy`, whose agent factory serves SDKs that post through Node's core HTTP modules — + * a path the worker never takes, since its own requests go through `fetch`. + */ + +/** Constructible placeholder: an agent built here would have no transport to pool. */ +export class Agent { + /** Teardown is accepted so disposal paths stay quiet. */ + destroy(): void { + // No socket pool was ever held. + } +} + +/** + * TLS requests have no carrier in a worker. + * @returns Never — it throws naming the unavailable member. + */ +export function request(): never { + throw new Error('web-preview: node:https.request is not available in the worker host') +} + +/** + * Counterpart of {@link request} for the GET shorthand. + * @returns Never — it throws naming the unavailable member. + */ +export function get(): never { + throw new Error('web-preview: node:https.get is not available in the worker host') +} + +/** + * TLS listening belongs to the host, not to a worker. + * @returns Never — it throws naming the unavailable member. + */ +export function createServer(): never { + throw new Error('web-preview: node:https.createServer is not available in the worker host') +} + +/** CommonJS interop marker: the worker loader hands `default` to default imports (see ./builtins.ts). */ +export const __esModule = true + +/** + * The `node:https` declarations this module stands in for. `Agent` keeps this module's own class: + * Node declares it over a socket pool that a placeholder holding no connection cannot expose. + */ +type NodeFace = Partial> & Record<'Agent', unknown> + +/** CommonJS default export: the members `require()` hands a caller of this module. */ +export default { Agent, request, get, createServer } satisfies NodeFace diff --git a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts index ea4f1d02a8..c83f656e32 100644 --- a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts @@ -16,6 +16,7 @@ import { notAvailableError, notImplementedFail } from '../../src/node/notImpleme import * as childProcess from '../../src/node/builtin_modules/implemented/child_process.ts' import * as dnsPromises from '../../src/node/builtin_modules/mock/dns/promises.ts' import * as net from '../../src/node/builtin_modules/mock/net.ts' +import * as https from '../../src/node/builtin_modules/mock/https.ts' import * as sqlite from '../../src/node/builtin_modules/mock/sqlite.ts' import * as stream from '../../src/node/builtin_modules/implemented/stream.ts' import * as vm from '../../src/node/builtin_modules/mock/vm.ts' @@ -128,6 +129,21 @@ describe('replaced external packages', () => { }) }) +describe('node:https placeholder', () => { + it('constructs an Agent but refuses every transport member', () => { + const agent = new https.Agent() + // Disposal paths run against agents that never pooled a socket. + expect(() => { agent.destroy() }).not.toThrow() + expect(() => https.request()).toThrow(/https.request is not available/) + expect(() => https.get()).toThrow(/https.get is not available/) + expect(() => https.createServer()).toThrow(/https.createServer is not available/) + }) + + it('exposes the same members through its CommonJS default', () => { + expect(Object.keys(https.default).sort()).toEqual(['Agent', 'createServer', 'get', 'request']) + }) +}) + describe('node:net address predicates', () => { it('classifies IPv4, IPv6, and neither', () => { expect([net.isIPv4('127.0.0.1'), net.isIPv4('255.255.255.255')]).toEqual([true, true]) From ca20b9af1111ff4afd14226096e423e9ec9f29ac Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 15:12:44 +0800 Subject: [PATCH 004/131] test(llm): cover the DeepSeek and pi-ai request paths with egress tests Both adapters' inference requests were argued from a code read and, for DeepSeek, from one product smoke. Drive each shipping adapter at an unresolvable endpoint through a fake proxy instead, and cover pi-ai's provider stream rather than only its model discovery. --- packages/llm/llm-deepseek/package.json | 19 ++--- .../llm/llm-deepseek/tests/egress.spec.ts | 69 +++++++++++++++++++ packages/llm/llm-deepseek/tsconfig.json | 3 + packages/llm/llm-pi-ai/tests/egress.spec.ts | 28 ++++++++ pnpm-lock.yaml | 3 + 5 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 packages/llm/llm-deepseek/tests/egress.spec.ts diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index ebddd5b4f5..7d123f214f 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -52,23 +52,24 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", - "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-anonymous-user-id": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^" } } diff --git a/packages/llm/llm-deepseek/tests/egress.spec.ts b/packages/llm/llm-deepseek/tests/egress.spec.ts new file mode 100644 index 0000000000..02032d3098 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/egress.spec.ts @@ -0,0 +1,69 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(`REQ ${request.url ?? ''}`) + response.writeHead(502); response.end('fake-proxy') + }) + proxy.on('connect', (request, socket) => { + seen.push(`CONNECT ${request.url ?? ''}`) + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() + }) + const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) + proxyUrl = `http://127.0.0.1:${String(a.port)}` +}) +afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) + +function policy(): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +} +async function observe(run: () => Promise): Promise { + seen = [] + const dispose = await installGlobalProxy(policy()) + try { await run().catch(() => undefined) } finally { await dispose() } + return seen +} +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import DeepSeekLlmApiExtensionRegistry from '@deepseek-ai/dsh-deepseek-llm-api-extensions' +import * as LlmDeepSeek from '../src/index.ts' + +let home: string +beforeAll(() => { + home = mkdtempSync(join(tmpdir(), 'dsh-deepseek-egress-')) + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('DEEPSEEK_API_KEY', 'probe-key') +}) +afterAll(() => { + vi.unstubAllEnvs() + rmSync(home, { recursive: true, force: true }) +}) + +/** Drive the shipping adapter's chat-completions request at an unresolvable endpoint. */ +async function streamOnce(): Promise { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(DeepSeekLlmApiExtensionRegistry) + await ctx.plugin(LlmDeepSeek, { baseURL: 'http://deepseek-probe.invalid/v1', models: [{ id: 'm' }] }) + for await (const _chunk of ctx.llm.stream({ provider: 'deepseek-official', model: 'm', messages: [] })) { + // The endpoint never answers; the proxy record is the assertion. + } +} + +describe('llm-deepseek egress', () => { + it('sends the chat-completions request through the proxy', async () => { + const observed = await observe(streamOnce) + expect(observed.join('|')).toContain('deepseek-probe.invalid') + }) +}) diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index 81b7bb1eca..d072109e35 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -55,6 +55,9 @@ }, { "path": "../../identity/anonymous-user-id" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/llm/llm-pi-ai/tests/egress.spec.ts b/packages/llm/llm-pi-ai/tests/egress.spec.ts index d1c23e13f5..15283a89ca 100644 --- a/packages/llm/llm-pi-ai/tests/egress.spec.ts +++ b/packages/llm/llm-pi-ai/tests/egress.spec.ts @@ -30,10 +30,38 @@ async function observe(run: () => Promise): Promise { try { await run().catch(() => undefined) } finally { await dispose() } return seen } +import { vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime from '@deepseek-ai/dsh-llm' +import * as LlmPiAi from '../src/index.ts' import { discoverModels } from '../src/discovery.ts' + +/** Drive the shipping adapter's provider stream at an unresolvable endpoint. */ +async function streamOnce(): Promise { + vi.stubEnv('PI_TEST_KEY', 'probe-key') + try { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmPiAi, { + providers: { deepseek: { apiKeyEnv: 'PI_TEST_KEY', baseURL: 'http://pi-stream-probe.invalid' } }, + }) + for await (const _chunk of ctx.llm.stream({ provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })) { + // The endpoint never answers; the proxy record is the assertion. + } + } finally { + vi.unstubAllEnvs() + } +} describe('pi-ai discovery egress', () => { it('goes through the proxy', async () => { const observed = await observe(() => discoverModels({ baseURL: 'http://pi-probe.invalid/v1', api: 'openai-completions', apiKey: 'probe' })) expect(observed.join('|')).toContain('pi-probe.invalid') }) }) + +describe('pi-ai provider stream egress', () => { + it('sends the inference request through the proxy', async () => { + const observed = await observe(streamOnce) + expect(observed.join('|')).toContain('pi-stream-probe.invalid') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7193f6c0d7..14bac20ab6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6443,6 +6443,9 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants From aa0ed35ebcbcf4e3cd6c1b6606fe9e917ae04b8a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 15:44:49 +0800 Subject: [PATCH 005/131] test(snapshot): isolate a replayed dsh from the machine's proxy environment A replay must not depend on the runner's network policy, the same reason it pins its home and sessions root. The harness now honors the proxy environment, so a runner exporting one sent the web-fetch scenario's fixture request to a proxy that could not resolve the fixture host and recorded that proxy's error page. Clear the proxy names in both test-support spawners, from the one list dsh-http-proxy owns. --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 6 ++++-- docs/module-graph.zh.md | 6 ++++-- packages/net/http-proxy/src/index.ts | 1 + packages/net/http-proxy/src/install.ts | 12 +---------- packages/net/http-proxy/src/policy.ts | 21 +++++++++++++++++++ .../test-support/loader-smoke/package.json | 10 +++++---- .../test-support/loader-smoke/src/index.ts | 15 ++++++++++++- .../test-support/loader-smoke/tsconfig.json | 3 +++ .../session-snapshot/package.json | 8 ++++--- .../session-snapshot/src/harness.ts | 15 +++++++++++++ .../session-snapshot/tsconfig.json | 3 +++ pnpm-lock.yaml | 6 ++++++ scripts/run-gates.spec.ts | 2 +- 17 files changed, 90 insertions(+), 30 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 867063c3a6..6078ac2978 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 6911366cd70f7fcf0bf0c0ed62d79e0bcd55e680 -config-catalog.zh.md: 69a069624732ca5111c14a3939fa9c43e26d961c +config-catalog.md: ab3b85626ac2e4910240dc612b2416c5f8381ad7 +config-catalog.zh.md: 75c19554ecbdf7e66e9451b0c3a9c87dfbde8236 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6911366cd7..ab3b85626a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -955,7 +955,7 @@ export interface ProxyConfig { } ``` -Source: [`packages/net/http-proxy/src/index.ts:49`](../packages/net/http-proxy/src/index.ts) +Source: [`packages/net/http-proxy/src/index.ts:50`](../packages/net/http-proxy/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 69a0696247..75c19554ec 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -957,7 +957,7 @@ export interface ProxyConfig { } ``` -来源:[`packages/net/http-proxy/src/index.ts:47`](../packages/net/http-proxy/src/index.ts) +来源:[`packages/net/http-proxy/src/index.ts:50`](../packages/net/http-proxy/src/index.ts) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 05b8931616..aff332a6ee 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 4aa7db428e91719bdd6aaee385c41af903922726 -module-graph.zh.md: 7d390651a8887946b9354b80964e185b5c2c8223 +module-graph.md: 773ffbcf209f922922a255e5b28e7758a9448c96 +module-graph.zh.md: f119320d79353a0d85f845cffdf0ef23d57bf8ab diff --git a/docs/module-graph.md b/docs/module-graph.md index 4aa7db428e..773ffbcf20 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -511,6 +511,7 @@ flowchart TD pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_settings --> pkg_session + pkg_session_snapshot --> pkg_http_proxy pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_agent --> pkg_invariants @@ -700,6 +701,7 @@ flowchart TD pkg_terminal --> pkg_brand pkg_terminal --> pkg_invariants pkg_loader_smoke --> pkg_agent + pkg_loader_smoke --> pkg_http_proxy pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session @@ -1824,7 +1826,7 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | @@ -1862,7 +1864,7 @@ flowchart TD | [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 7d390651a8..f119320d79 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -513,6 +513,7 @@ flowchart TD pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_settings --> pkg_session + pkg_session_snapshot --> pkg_http_proxy pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session pkg_agent --> pkg_invariants @@ -702,6 +703,7 @@ flowchart TD pkg_terminal --> pkg_brand pkg_terminal --> pkg_invariants pkg_loader_smoke --> pkg_agent + pkg_loader_smoke --> pkg_http_proxy pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session @@ -1826,7 +1828,7 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`settings`](../packages/settings/settings) | `settings` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | -| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`fs`](../packages/fs/fs) | `fs` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/runtime-diagnostics/invariants), [`spill`](../packages/spill/spill) | @@ -1864,7 +1866,7 @@ flowchart TD | [`bash-local`](../packages/shell/bash-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) | | [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | diff --git a/packages/net/http-proxy/src/index.ts b/packages/net/http-proxy/src/index.ts index 2d0efc12b0..1520a88506 100644 --- a/packages/net/http-proxy/src/index.ts +++ b/packages/net/http-proxy/src/index.ts @@ -27,6 +27,7 @@ export { resolveProxyPolicy, DIRECT_POLICY, LOOPBACK_NO_PROXY, + PROXY_ENV_NAMES, type ProxyConfig, type ProxyDiagnostic, type ProxyPolicy, diff --git a/packages/net/http-proxy/src/install.ts b/packages/net/http-proxy/src/install.ts index 81a52bb5fb..58182306d2 100644 --- a/packages/net/http-proxy/src/install.ts +++ b/packages/net/http-proxy/src/install.ts @@ -9,18 +9,8 @@ */ import type { Agent, Dispatcher } from 'undici' -import { DIRECT_POLICY, proxyForUrl, type ProxyPolicy } from './policy.ts' +import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from './policy.ts' -/** - * The environment names each policy field owns, lowercase first. Both casings are written together: - * undici reads the lowercase name first, so leaving a stale uppercase value behind would let it - * shadow the resolved one on Windows, where the two names are the same variable. - */ -const POLICY_ENV_NAMES = { - httpProxy: ['http_proxy', 'HTTP_PROXY'], - httpsProxy: ['https_proxy', 'HTTPS_PROXY'], - noProxy: ['no_proxy', 'NO_PROXY'], -} as const /** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */ let active: ProxyPolicy | undefined diff --git a/packages/net/http-proxy/src/policy.ts b/packages/net/http-proxy/src/policy.ts index 07e47d9f4d..9be82d7f9f 100644 --- a/packages/net/http-proxy/src/policy.ts +++ b/packages/net/http-proxy/src/policy.ts @@ -20,6 +20,27 @@ import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environm */ export const LOOPBACK_NO_PROXY: readonly string[] = ['localhost', '127.0.0.1', '::1', '[::1]'] +/** + * The environment names each policy field owns, lowercase first — undici reads the lowercase name + * first, so both casings are always written or cleared together. + */ +export const POLICY_ENV_NAMES = { + httpProxy: ['http_proxy', 'HTTP_PROXY'], + httpsProxy: ['https_proxy', 'HTTPS_PROXY'], + noProxy: ['no_proxy', 'NO_PROXY'], +} as const + +/** + * Every environment name that carries proxy configuration, including the `ALL_PROXY` fallback this + * package resolves but never writes back. A caller that must isolate a child from the machine's + * network policy clears exactly these. + */ +export const PROXY_ENV_NAMES: readonly string[] = [ + ...Object.values(POLICY_ENV_NAMES).flat(), + 'all_proxy', + 'ALL_PROXY', +] + /** Proxy URL schemes this package routes through. Everything else is reported, never silently dropped. */ const SUPPORTED_PROTOCOLS = new Set(['http:', 'https:']) diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 067b179cd0..6bb6111b47 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -36,18 +36,20 @@ "tsx": "^4.22.4" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^" } } diff --git a/packages/test-support/loader-smoke/src/index.ts b/packages/test-support/loader-smoke/src/index.ts index ecdbfb9bbb..b5f506c433 100644 --- a/packages/test-support/loader-smoke/src/index.ts +++ b/packages/test-support/loader-smoke/src/index.ts @@ -11,6 +11,7 @@ * @module @deepseek-ai/dsh-loader-smoke */ +import { PROXY_ENV_NAMES } from '@deepseek-ai/dsh-http-proxy' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -30,6 +31,14 @@ export const LOADER_SMOKE_TEST_TIMEOUT_MS = DEFAULT_PROCESS_TIMEOUT_MS + 15_000 /** Which artifact an example bin is booted from: unbuilt `src` via tsx, or built `lib` via plain Node. */ export type ExampleMode = 'src' | 'lib' +/** + * Proxy names cleared from every smoke child. + * @returns an environment overlay removing each name that carries proxy configuration. + */ +function clearedProxyEnv(): NodeJS.ProcessEnv { + return Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, undefined])) +} + /** Environment variable selecting the mode; CI sets it to `lib`, dev leaves it unset (`src`). */ export const EXAMPLE_MODE_ENV = 'DSH_EXAMPLE_MODE' @@ -109,7 +118,11 @@ function toLibBin(srcBin: string): string { export function resolveExampleLaunch(options: ExampleLaunchOptions): ExampleLaunch { const mode = options.mode ?? resolveExampleMode() const configArgs = options.configArgs ?? [] - const env: NodeJS.ProcessEnv = { ...options.env } + // A smoke launches a real `dsh` against local fixtures, so it must not inherit the machine's + // network policy: the harness honors the proxy environment, and a runner that exports one would + // send a fixture-server request to a proxy that cannot resolve the fixture host. `undefined` + // removes the name from the child rather than setting it empty. + const env: NodeJS.ProcessEnv = { ...clearedProxyEnv(), ...options.env } if (mode === 'src') { if (options.tsconfigPath === undefined) { diff --git a/packages/test-support/loader-smoke/tsconfig.json b/packages/test-support/loader-smoke/tsconfig.json index d593ea20b7..f4e19d079f 100644 --- a/packages/test-support/loader-smoke/tsconfig.json +++ b/packages/test-support/loader-smoke/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index 57c4b94d81..0b167487d1 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -39,14 +39,17 @@ "vitest": "^4.1.8" }, "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-compaction": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", @@ -55,7 +58,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", - "@deepseek-ai/cordis": "workspace:^", "@types/js-yaml": "^4.0.9" } } diff --git a/packages/test-support/session-snapshot/src/harness.ts b/packages/test-support/session-snapshot/src/harness.ts index 628d334337..78a3a6e0a9 100644 --- a/packages/test-support/session-snapshot/src/harness.ts +++ b/packages/test-support/session-snapshot/src/harness.ts @@ -35,8 +35,17 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from './launcher.ts' +import { PROXY_ENV_NAMES } from '@deepseek-ai/dsh-http-proxy' import { captureWorkspaceSnapshot, type WorkspaceSnapshotEntry } from './workspace.ts' +/** + * Proxy names removed from every replayed child. + * @returns an environment overlay removing each name that carries proxy configuration. + */ +function clearedProxyEnv(): NodeJS.ProcessEnv { + return Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, undefined])) +} + export type { AgentUnderTest } from './launcher.ts' const DEFAULT_WAIT_TIMEOUT_MS = 10_000 @@ -257,6 +266,12 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise }) const env: NodeJS.ProcessEnv = { ...opts.env, + // A replay must not depend on the machine's network policy, the same reason it pins its home + // and sessions root. The harness honors the proxy environment, so a runner that exports one + // would send a scenario's fixture-server request to a proxy that cannot resolve the fixture + // host and record that proxy's error page as the expected output. `undefined` removes the + // name from the child rather than setting it empty. + ...clearedProxyEnv(), DSH_SNAPSHOT: opts.mode, DSH_SNAPSHOT_FILE: opts.fixtureFile, DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, diff --git a/packages/test-support/session-snapshot/tsconfig.json b/packages/test-support/session-snapshot/tsconfig.json index b258ab7851..8874db4c1b 100644 --- a/packages/test-support/session-snapshot/tsconfig.json +++ b/packages/test-support/session-snapshot/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../net/http-proxy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 14bac20ab6..c3ccfce670 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9510,6 +9510,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9550,6 +9553,9 @@ importers: '@deepseek-ai/dsh-compaction': specifier: workspace:^ version: link:../../compaction/compaction + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../net/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 0033a55958..74db1b0c71 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -104,7 +104,7 @@ describe('gate graph validation', () => { expect(ids).toEqual([ 'rescope-vendor', 'publint', 'constraints', 'application-entrypoints', 'dsh-package-licenses', 'package-invariants', 'built-package-invariants', 'node-next-types', - 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'cordis-config', + 'optional-dependency-imports', 'client-packages', 'client-ui-i18n', 'no-bare-dispatcher', 'cordis-config', 'runtime-closure', 'vendored-links', ]) expect(defaultConcurrency('hygiene', ids.length, 8)).toEqual({ From e6dbf85f6c14f3b3d28000da5fb887458edaac64 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 16:50:31 +0800 Subject: [PATCH 006/131] =?UTF-8?q?fix(net):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20containment,=20opt-out,=20and=20syntax-aware=20disc?= =?UTF-8?q?overy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow worker no longer receives proxy configuration: it executes the model-authored script body, and a proxy URL may carry credentials. A child process now inherits the values the user exported rather than this process's normalization, so a SOCKS proxy set for curl survives and no HTTPS_PROXY is invented. `mode: 'off'` installs a direct dispatcher instead of recording a policy the global dispatcher ignores, and the environment snapshot is taken before any write so Windows restores the user's values. E2B picks its proxy from the control-plane URL the SDK will really call, the OTLP agent honors `exporter.keepAlive`, and a scheme whose own value was refused stays direct instead of borrowing another scheme's proxy. verify-no-bare-dispatcher parses the TypeScript AST as scripts/AGENTS.md requires; it immediately found the `{ dispatcher }` shorthand the regex missed. --- ...2026-08-27-outbound-proxy-policy.i18n.yaml | 4 +- .../2026-08-27-outbound-proxy-policy.md | 8 +- .../2026-08-27-outbound-proxy-policy.zh.md | 8 +- packages/e2b/e2b/package.json | 1 + packages/e2b/e2b/src/index.ts | 26 ++- packages/e2b/e2b/tests/egress.spec.ts | 23 +++ packages/e2b/e2b/tsconfig.json | 3 + packages/net/http-proxy/README.i18n.yaml | 4 +- packages/net/http-proxy/README.md | 11 +- packages/net/http-proxy/README.zh.md | 11 +- packages/net/http-proxy/src/install.ts | 81 +++++++--- packages/net/http-proxy/src/policy.ts | 73 ++++++--- packages/net/http-proxy/tests/install.spec.ts | 61 +++++-- .../http-proxy/tests/matcher-parity.spec.ts | 63 ++++++++ packages/net/http-proxy/tests/policy.spec.ts | 23 +++ .../session-telemetry-otel/src/index.ts | 6 +- .../tests/egress.spec.ts | 60 ++++++- packages/subprocess/subprocess/package.json | 3 +- packages/subprocess/subprocess/src/index.ts | 10 +- .../subprocess/tests/egress.spec.ts | 150 ++++++++++++------ packages/subprocess/subprocess/tsconfig.json | 3 + packages/web/web-fetch-http/src/network.ts | 1 + .../workflow-worker-thread/src/host.ts | 15 +- .../tests/egress.spec.ts | 83 +++------- pnpm-lock.yaml | 6 + scripts/verify-no-bare-dispatcher.spec.ts | 60 +++++-- scripts/verify-no-bare-dispatcher.ts | 125 ++++++++++++--- 27 files changed, 681 insertions(+), 241 deletions(-) create mode 100644 packages/net/http-proxy/tests/matcher-parity.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml index 274440f20f..6bbb407722 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md -2026-08-27-outbound-proxy-policy.md: 9d0a1545e68652f2f2453e89fbf6321385b6c804 -2026-08-27-outbound-proxy-policy.zh.md: fe4f596c6839b7b1c275abf042b90d08b42643c1 +2026-08-27-outbound-proxy-policy.md: 0213ab056bb3c4eb417788b739daca0adc5be669 +2026-08-27-outbound-proxy-policy.zh.md: 312b9197acfb47edb51eb4413e15c57b492cdce6 diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md index 9d0a1545e6..0213ab056b 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md @@ -26,13 +26,13 @@ This keeps `proxyForUrl()` and the dispatcher answering from one set of values. **Resolution supplies what neither Node nor undici does.** `ALL_PROXY` backs both schemes; a blank value counts as unset, because undici's `??` chain lets an empty lowercase name shadow a populated uppercase one; loopback is always bypassed, since the Web UI, the Connection transport, and every local test server would otherwise route through the proxy and loop. The bypass list carries `::1` *and* `[::1]`: undici's own matcher reads a bare `::1` as host `:` port `1` and never exempts it. -**Rejection is loud or quiet by where the value came from.** A SOCKS URL, an unparseable string, or an unsupported scheme *from the environment* is reported on stderr and skipped — that variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The same value through the plugin's `Config` throws at load, because that is the harness's own configuration surface, where `AGENTS.md` requires misconfiguration to fail loud. +**Rejection is loud or quiet by where the value came from, and never reroutes the refused scheme.** A slot the user filled and this package refused keeps that scheme direct rather than falling through to `ALL_PROXY` or the HTTP proxy, so the diagnostic and the route agree. A SOCKS URL, an unparseable string, or an unsupported scheme *from the environment* is reported on stderr and skipped — that variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The same value through the plugin's `Config` throws at load, because that is the harness's own configuration surface, where `AGENTS.md` requires misconfiguration to fail loud. **Through a proxy, `web_fetch` stops resolving and pinning.** The provider validates a public address set and pins the connection to it. Through a proxy there is nothing to pin — the proxy performs the origin's DNS — and a pinned direct connection would bypass the proxy entirely. So a proxied hop skips resolution, and configuring a proxy is a statement that the proxy is trusted with destination selection. A hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, takes the resolved-and-pinned path unchanged. Kimi Code and Claude Code reached this same conclusion independently. The URL-level policy is untouched: `http(s)` only, no embedded credentials, the length cap, and the cross-origin redirect refusal all still apply on every hop. -**A separate Node execution context gets the policy through its environment.** `childProxyEnv()` returns the resolved names plus `NODE_USE_ENV_PROXY=1`, merged into `scrubbedParentEnv()` — one function every spawner already shares — and into `workerSpawnEnv()`. A worker thread has its own `globalThis` and does not inherit the global dispatcher, and its environment is built explicitly rather than inherited, so it needs the names as well as the flag. Measured: the flag does take effect for a `Worker` given an explicit `env`. +**A spawned child gets the policy through its environment; a model-executing worker gets nothing.** `childProxyEnv()` merges into `scrubbedParentEnv()`, the one function every spawner already shares. The workflow worker does NOT receive it: it executes the model-authored script body, and a proxy URL may carry `user:password`. That is the same containment the code runtime keeps and `docs/defensive-patterns.md` requires, so a workflow's own requests go direct. This accepts a documented seam. Such a context matches bypass entries by Node's rules, which differ from this package's in separators and IPv4-range support, and the flag exists only on Node 22.21+ and 24+. @@ -40,7 +40,7 @@ This accepts a documented seam. Such a context matches bypass entries by Node's **Every call site carries an egress test, because reading the code was not enough.** `egress.spec.ts` in each owning package drives that site's real code path at an unresolvable `.invalid` host through a fake proxy and asserts the proxy saw the request. Nine of them cover the search backends, pi-ai discovery, MCP over HTTP, telemetry, E2B, a spawned child Node, and a worker thread. The gate below cannot see inside a dependency; these can, and they are what turns "an SDK changed its transport" from a silent regression into a failing test. -**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` rejects `new Agent(...)` and an explicit `dispatcher:` outside the owning package. `createDispatcher(url, options)` is the sanctioned replacement, and a line that must genuinely ignore the proxy says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it. +**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `createDispatcher(url, options)` is the sanctioned replacement, and a line that must genuinely ignore the proxy says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it. ## Alternatives considered @@ -78,6 +78,6 @@ The suite is hermetic against the developer's own environment: `plugin.spec.ts` `verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `createDispatcher`, accepts an annotated exemption, and passes on the current tree. -The egress suite also carries the negative case for telemetry: without the agent this change supplies, the exporter reaches no proxy at all. That assertion is what keeps the fix from being quietly reverted by an SDK upgrade that restores the default agent. +The egress suite carries the negative case for telemetry — restoring the SDK's own default agent reaches no proxy — so an upgrade cannot quietly un-proxy it. Its positive case branches on the runtime, because the exporter's agent needs Node 22.21+ or 24.5+. A parity suite pins the two bypass matchers (`proxyForUrl` and the installed `EnvHttpProxyAgent`) against each other over the documented `NO_PROXY` vocabulary. No recorded-session snapshot changes: nothing here alters a model-visible input or product-user-visible transcript output. diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md index fe4f596c68..312b9197ac 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md @@ -26,13 +26,13 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 **解析补上 Node 与 undici 都不提供的部分。** `ALL_PROXY` 为两种协议兜底;空值视为未设置,因为 undici 的 `??` 链会让空的小写名遮住有值的大写名;loopback 始终绕过,否则 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。绕过列表同时携带 `::1` **与** `[::1]`:undici 自带的匹配器会把裸写的 `::1` 读成主机 `:` 端口 `1`,从而永不豁免它。 -**拒绝是响还是静,取决于值从哪来。** 来自**环境**的 SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。同样的值若经由插件的 `Config` 传入,则在加载期抛出,因为那是 Harness 自己的配置面,`AGENTS.md` 要求配置错误必须响。 +**拒绝是响还是静取决于值从哪来,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。来自**环境**的 SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。同样的值若经由插件的 `Config` 传入,则在加载期抛出,因为那是 Harness 自己的配置面,`AGENTS.md` 要求配置错误必须响。 **经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。 URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。 -**独立的 Node 执行上下文通过环境获得策略。** `childProxyEnv()` 返回解析出的变量名加上 `NODE_USE_ENV_PROXY=1`,并入 `scrubbedParentEnv()`(每个 spawner 本就共享的一个函数)与 `workerSpawnEnv()`。worker 线程拥有独立的 `globalThis`,不继承全局 dispatcher,而且它的环境是显式构造而非继承的,因此除标志外还需要那些变量名。已实测:对给定显式 `env` 的 `Worker`,该标志确实生效。 +**派生的子进程通过环境获得策略;执行模型代码的 worker 什么也不获得。** `childProxyEnv()` 并入 `scrubbedParentEnv()`——每个 spawner 本就共享的那一个函数。workflow worker **不**接收它:它执行的是模型编写的脚本体,而代理 URL 可能携带 `user:password`。这与 code runtime 保持的containment 相同,也是 `docs/defensive-patterns.md` 的要求,因此 workflow 自身的请求直连。 这接受了一处已记录的接缝。此类上下文按 Node 自己的规则匹配绕过条目,其分隔符与 IPv4 区间支持与本包不同,且该标志仅存在于 Node 22.21+ 与 24+。 @@ -40,7 +40,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 **每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、遥测、E2B、派生的子 Node 与 worker 线程。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。 -**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 在所属包之外拒绝 `new Agent(...)` 与显式 `dispatcher:`。`createDispatcher(url, options)` 是受支持的替代;确实必须忽略代理的行用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。 +**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`createDispatcher(url, options)` 是受支持的替代;确实必须忽略代理的行用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。 ## Alternatives considered @@ -78,6 +78,6 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l `verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `createDispatcher`、接受带注释的豁免,并在当前代码树上通过。 -出网测试还为遥测保留了负向用例:不带本次提供的 agent 时,导出器完全触及不到代理。该断言可以防止某次 SDK 升级恢复默认 agent 后把修复悄悄回退掉。 +出网测试为遥测保留了负向用例——恢复 SDK 自带的默认 agent 就触及不到代理——因此升级无法悄悄把它变回直连。其正向用例按运行时分支,因为导出器的 agent 需要 Node 22.21+ 或 24.5+。另有一组一致性测试,用文档所述的 `NO_PROXY` 词汇把两套绕过匹配器(`proxyForUrl` 与已安装的 `EnvHttpProxyAgent`)相互固定。 无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。 diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 1d65f2de80..b01fbcdaae 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-fs-e2b": "workspace:^", "@deepseek-ai/dsh-http-proxy": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-lsp": "workspace:^", "@deepseek-ai/dsh-lsp-stdio": "workspace:^", diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 428f7b25c1..6a88f35b66 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -70,6 +70,25 @@ declare module '@deepseek-ai/cordis' { /** The SDK's own default control-plane domain; `E2B_DOMAIN` overrides it there and here alike. */ const E2B_DEFAULT_DOMAIN = 'e2b.app' +/** The debug control plane the SDK substitutes, on loopback and plain HTTP. */ +const E2B_DEBUG_API_URL = 'http://localhost:3000' + +/** + * The control-plane URL the SDK will actually call, derived the way the SDK derives it: an explicit + * `E2B_API_URL` first, then the debug substitute, then the domain default. Choosing a proxy for + * anything else would pick the wrong scheme's proxy, ignore a bypass entry naming the real host, and + * — for the loopback debug plane — hand a proxy the control-plane traffic and its API key. + * + * @param env - the process environment to read; overridable so tests need no ambient state. + * @returns the absolute control-plane URL. + */ +export function e2bApiUrl(env: NodeJS.ProcessEnv = process.env): string { + const explicit = env.E2B_API_URL + if (explicit !== undefined && explicit !== '') return explicit + if ((env.E2B_DEBUG ?? 'false').toLowerCase() === 'true') return E2B_DEBUG_API_URL + return `https://api.${env.E2B_DOMAIN ?? E2B_DEFAULT_DOMAIN}` +} + /** * Creates one lazily consumable E2B SDK handle and deletes the sandbox at * timeout or disposal. Creation begins at plugin construction; adapters await @@ -154,9 +173,10 @@ export class E2BRuntime extends Service { private async open(): Promise { // The SDK builds its own undici dispatcher, so the global one never reaches it; it takes a proxy - // URL instead and reads no environment of its own. Its control-plane origin follows `E2B_DOMAIN` - // exactly as the SDK derives it, so a bypass entry naming that host is honored. - const proxy = proxyUrlFor(new URL(`https://api.${process.env.E2B_DOMAIN ?? E2B_DEFAULT_DOMAIN}`)) + // URL instead and reads no environment of its own. The decision is made against the URL the SDK + // will really call, so a bypass entry naming that host is honored and a loopback debug plane + // stays direct. + const proxy = proxyUrlFor(new URL(e2bApiUrl())) const sandbox = await Sandbox.create({ apiKey: this.config.apiKey, timeoutMs: this.config.timeoutMs, diff --git a/packages/e2b/e2b/tests/egress.spec.ts b/packages/e2b/e2b/tests/egress.spec.ts index ff295cba16..1c3ac15fe4 100644 --- a/packages/e2b/e2b/tests/egress.spec.ts +++ b/packages/e2b/e2b/tests/egress.spec.ts @@ -44,3 +44,26 @@ describe('e2b egress', () => { expect(observed.join('|')).toContain('api.e2b.app:443') }) }) + +describe('e2b control-plane URL', () => { + it('follows the SDK precedence so the proxy decision matches the real target', async () => { + const { e2bApiUrl } = await import('../src/index.ts') + expect(e2bApiUrl({})).toBe('https://api.e2b.app') + expect(e2bApiUrl({ E2B_DOMAIN: 'e2b.dev' })).toBe('https://api.e2b.dev') + expect(e2bApiUrl({ E2B_DEBUG: 'TRUE' })).toBe('http://localhost:3000') + expect(e2bApiUrl({ E2B_API_URL: 'https://api.internal.example', E2B_DEBUG: 'true' })) + .toBe('https://api.internal.example') + }) + + it('keeps the loopback debug plane direct instead of sending its API key to a proxy', async () => { + const { e2bApiUrl } = await import('../src/index.ts') + const { proxyForUrl, resolveProxyPolicy } = await import('@deepseek-ai/dsh-http-proxy') + const { createLaunchEnvironmentSnapshot } = await import('@deepseek-ai/dsh-launch-environment') + // A resolved policy — the shape a real launch installs — always bypasses loopback. + const { policy: resolved } = resolveProxyPolicy( + createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]), + ) + expect(proxyForUrl(resolved, new URL(e2bApiUrl({ E2B_DEBUG: 'true' })))).toBeUndefined() + expect(proxyForUrl(resolved, new URL(e2bApiUrl({})))).toBe(proxyUrl) + }) +}) diff --git a/packages/e2b/e2b/tsconfig.json b/packages/e2b/e2b/tsconfig.json index 16b08d6ea5..304c40efd8 100644 --- a/packages/e2b/e2b/tsconfig.json +++ b/packages/e2b/e2b/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../net/http-proxy" + }, + { + "path": "../../util/launch-environment" } ] } diff --git a/packages/net/http-proxy/README.i18n.yaml b/packages/net/http-proxy/README.i18n.yaml index eb6ba9e4c9..8a95acfe0b 100644 --- a/packages/net/http-proxy/README.i18n.yaml +++ b/packages/net/http-proxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/net/http-proxy/README.md -README.md: 17c92824b70bb017b11a0635edbdbbad9e8f48ae -README.zh.md: b18db4e5a91edc499b33369c13f62b2fbba374ca +README.md: 3c0cf6ff7deb915d11104ef5a7fb622dfb951385 +README.zh.md: c6d26b05c1d4fcb4603518a0725cad988efae65a diff --git a/packages/net/http-proxy/README.md b/packages/net/http-proxy/README.md index 17c92824b7..3c0cf6ff7d 100644 --- a/packages/net/http-proxy/README.md +++ b/packages/net/http-proxy/README.md @@ -36,7 +36,7 @@ Plain `fetch()` is proxied, and so is any SDK that reaches `globalThis.fetch` | A call needing its own agent options (pool size, timeouts, a DNS lookup) | `createDispatcher(url, options)` | | An SDK that takes a `node:http` agent | `createNodeHttpAgent(protocol, options)` | | An SDK that takes a proxy URL of its own | `proxyUrlFor(url)` | -| A worker thread, or a spawn whose environment you build yourself | merge `childProxyEnv()` into its environment | +| A spawn whose environment you build yourself | apply `childProxyEnv()` to it (`undefined` means remove) | 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; a line that must genuinely ignore the proxy says so with a `proxy-exempt:` comment. @@ -61,7 +61,7 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri **One resolution, two readers.** `proxyForUrl()` and the installed dispatcher must never disagree about a URL, or `dsh-web-fetch-http` would pin a connection the dispatcher meant to tunnel. Installation therefore publishes the resolved policy into the proxy environment variables and constructs `EnvHttpProxyAgent` with no options, so the agent reads back exactly what was resolved rather than re-parsing the raw environment under slightly different rules. -**Publishing the policy is also how children inherit it.** The same write normalizes what every spawned process sees: the `ALL_PROXY` fallback becomes a concrete `HTTP_PROXY`, and the bypass list arrives with loopback already merged. +**A child inherits what the user exported, not what this process resolved.** The published values exist for undici; `childProxyEnv()` restores each name to its original before a child is spawned. Normalizing them into a child would hand `curl` an `HTTPS_PROXY` invented from the HTTP one, or replace a SOCKS proxy the user set for `curl` with an HTTP proxy they never named for that scheme. ### Source map @@ -101,10 +101,11 @@ No direct invalidation: the package contributes no request tokens and never muta These limits define when the package is a poor fit. They are current package constraints. -- **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment or configuration. 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 skipped rather than silently ignored. +- **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment or configuration. 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. - **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. -- **A separate Node context matches bypass entries by Node's rules, not these** — a child process or worker thread honors the policy through Node's own `NODE_USE_ENV_PROXY` support, whose `NO_PROXY` parsing differs in separators and IPv4-range handling, and which exists only on Node 22.21+ and 24+. An older runtime keeps that context direct. -- **The `code-runtime` worker is deliberately excluded** — model-authored programs run with no ambient environment at all, and a proxy URL may carry credentials. +- **A separate Node context honors the policy only on a new enough runtime** — a spawned child reads it through Node's `NODE_USE_ENV_PROXY` (22.21+, 24+), and the OTLP exporter's agent through Node's `proxyEnv` option (22.21+, **24.5+**). The engines range admits 22.19, 22.20, and 24.0–24.4, where those two paths stay direct. Such a context also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in their separators and IPv4-range support. +- **A worker that executes model-authored code gets no proxy at all** — neither the `code-runtime` worker nor the `workflow` worker receives proxy configuration, so their own requests go direct. A proxy URL may carry `user:password`, and both run scripts the model wrote. +- **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`. ### Dev Note diff --git a/packages/net/http-proxy/README.zh.md b/packages/net/http-proxy/README.zh.md index b18db4e5a9..c6d26b05c1 100644 --- a/packages/net/http-proxy/README.zh.md +++ b/packages/net/http-proxy/README.zh.md @@ -36,7 +36,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代 | 需要自定义 agent 选项的调用(连接池、超时、DNS 查询) | `createDispatcher(url, options)` | | 接受 `node:http` agent 的 SDK | `createNodeHttpAgent(protocol, options)` | | 接受自有代理 URL 的 SDK | `proxyUrlFor(url)` | -| worker 线程,或由你自己构造环境的派生进程 | 把 `childProxyEnv()` 并入其环境 | +| 由你自己构造环境的派生进程 | 把 `childProxyEnv()` 应用到它上面(`undefined` 表示删除) | 构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法;确实必须忽略代理的行用 `proxy-exempt:` 注释说明理由。 @@ -61,7 +61,7 @@ loopback 始终被绕过。否则 Harness 自己的 Web UI、Connection 传输 **一次解析,两个读者。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此安装时会把解析出的策略发布到代理环境变量中,并以无选项方式构造 `EnvHttpProxyAgent`,让该 agent 读回的正是解析结果,而不是按略有差异的规则重新解析原始环境。 -**发布策略同时也是子进程继承的途径。** 同一次写入还规范化了每个派生进程看到的内容:`ALL_PROXY` 兜底落为具体的 `HTTP_PROXY`,绕过列表也已并入 loopback。 +**子进程继承的是用户导出的值,而非本进程解析出的值。** 写回环境只服务 undici;派生子进程前,`childProxyEnv()` 会把每个变量名还原为原值。把规范化结果塞给子进程,会让 `curl` 拿到一个由 HTTP 代理凭空推出的 `HTTPS_PROXY`,或让用户为 `curl` 设置的 SOCKS 代理被替换成他们从未为该协议指定过的 HTTP 代理。 ### 源码地图 @@ -101,10 +101,11 @@ loopback 始终被绕过。否则 Harness 自己的 Web UI、Connection 传输 这些限制界定了本包不适用的场景,属于当前的包级约束。 -- **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境或配置的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告并跳过,而不是静默忽略。 +- **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境或配置的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告,且该协议保持直连,不会借用另一协议的代理。 - **不支持自定义证书颁发机构**——做 TLS 拦截的企业代理需要在启动前为进程设置 `NODE_EXTRA_CA_CERTS`,本包既不设置也不校验它。 -- **独立的 Node 上下文按 Node 自己的规则匹配绕过条目,而非本包的规则**——子进程或 worker 线程通过 Node 自带的 `NODE_USE_ENV_PROXY` 支持来遵循策略,而它的 `NO_PROXY` 解析在分隔符与 IPv4 区间处理上与此处不同,且仅存在于 Node 22.21+ 与 24+。更旧的运行时会让该上下文保持直连。 -- **`code-runtime` worker 被刻意排除在外**——模型编写的程序运行时完全没有环境变量,而代理 URL 可能携带凭据。 +- **独立的 Node 上下文只在足够新的运行时上遵循策略**——派生的子进程通过 Node 的 `NODE_USE_ENV_PROXY` 读取(22.21+、24+),OTLP 导出器的 agent 则通过 Node 的 `proxyEnv` 选项(22.21+、**24.5+**)。engines 范围允许 22.19、22.20 与 24.0–24.4,在这些版本上这两条路径保持直连。此类上下文还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。 +- **执行模型编写代码的 worker 完全不获得代理**——`code-runtime` worker 与 `workflow` worker 都不接收代理配置,它们自身的请求直连。代理 URL 可能携带 `user:password`,而两者运行的都是模型写的脚本。 +- **防回归门禁只看源码,看不到依赖内部**——`verify-no-bare-dispatcher` 解析 `packages/*/*/src` 与 `apps/*/src`;测试、脚本以及第三方 SDK 的内部都在其之外。这正是每个出网点还各配一份 `egress.spec.ts` 的原因。 ### 开发备注 diff --git a/packages/net/http-proxy/src/install.ts b/packages/net/http-proxy/src/install.ts index 58182306d2..9192c4c7d9 100644 --- a/packages/net/http-proxy/src/install.ts +++ b/packages/net/http-proxy/src/install.ts @@ -15,6 +15,14 @@ import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from ' /** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */ let active: ProxyPolicy | undefined +/** + * The proxy environment as it stood before the active policy was published, or `undefined` when none + * is installed. A spawned child receives these, not the published ones: normalizing what this process + * resolved into a child's environment would replace a value the user set for another tool — a SOCKS + * proxy this package refuses but `curl` uses, or an `HTTPS_PROXY` the user never wrote at all. + */ +let inheritedProxyEnv: Readonly> | undefined + /** * The policy governing this process's outbound requests. * @@ -34,11 +42,17 @@ export function currentProxyPolicy(): ProxyPolicy | undefined { * @returns a function restoring every name this call changed. */ function applyPolicyEnv(policy: ProxyPolicy): () => void { + // Snapshot EVERY name before writing any of them. Windows folds environment names case-insensitively, + // so reading the uppercase spelling after writing the lowercase one would read back the value just + // written and restore the policy instead of the user's environment. const previous = new Map() + for (const names of Object.values(POLICY_ENV_NAMES)) { + for (const name of names) previous.set(name, process.env[name]) + } + inheritedProxyEnv = Object.fromEntries(previous) for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) { const value = policy[field as keyof typeof POLICY_ENV_NAMES] for (const name of names) { - previous.set(name, process.env[name]) if (value === undefined || value === '') Reflect.deleteProperty(process.env, name) else process.env[name] = value } @@ -48,6 +62,7 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void { if (value === undefined) Reflect.deleteProperty(process.env, name) else process.env[name] = value } + inheritedProxyEnv = undefined } } @@ -67,10 +82,26 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void { export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise> { const previousPolicy = active if (policy.source === 'none') { + // A direct policy mounted over an installed one must actually stop proxying. Recording the policy + // alone would leave the previous agent as the global dispatcher, so a plain `fetch()` would keep + // tunnelling while `proxyForUrl()` reported a direct connection — and `mode: 'off'` would be a + // silent no-op. With nothing installed there is nothing to displace. + if (previousPolicy === undefined) { + active = policy + return () => { + active = previousPolicy + return Promise.resolve() + } + } + const undici = await import('undici') + const previous = undici.getGlobalDispatcher() + const direct = new undici.Agent() + undici.setGlobalDispatcher(direct) active = policy - return () => { + return async () => { + undici.setGlobalDispatcher(previous) active = previousPolicy - return Promise.resolve() + await direct.close() } } const restoreEnv = applyPolicyEnv(policy) @@ -98,7 +129,9 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro * `verify-no-bare-dispatcher` enforces that outside this package. * * @param url - the request URL, which decides whether the policy proxies or bypasses it. - * @param options - agent options; applied to whichever agent the policy selects. + * @param options - agent options; applied to whichever agent the policy selects. On the proxied path + * `connect` governs the connection to the PROXY, not to the origin, so a lookup meant to pin an + * origin address belongs only on a URL the policy bypasses. * @returns a dispatcher the caller owns and must close once the response body is consumed. */ export async function createDispatcher(url: URL, options: Agent.Options = {}): Promise { @@ -147,29 +180,27 @@ export function proxyUrlFor(url: URL): string | undefined { } /** - * The proxy environment a separate Node execution context needs: the resolved policy plus the flag - * that makes Node's built-in HTTP clients honor it. + * The proxy environment a spawned child needs. * - * This covers both shapes DSH spawns. A child process inherits the parent environment, so the proxy - * names merely restate what {@link installGlobalProxy} already published and the flag is what it - * gains. A worker thread is given an explicit, near-empty environment instead, so it needs the names - * as well — and worker threads do not inherit the global dispatcher, which is why they are handled - * here rather than left to the parent's installation. + * A child inherits the parent environment, which this process rewrote to its own resolved policy so + * undici reads back exactly what was resolved. That normalization must not reach the child: it would + * hand `curl` an `HTTPS_PROXY` this package invented from the HTTP one, or replace a SOCKS proxy the + * user set for `curl` with an HTTP proxy they never named for that scheme. The result therefore + * restores each name to the value the user exported — `undefined` for a name they never set — and + * adds only the flag that makes a child Node honor them. * - * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that context direct. Such a - * context also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this - * package's in their separators and IPv4-range support. Non-Node children (curl, git, pnpm) ignore - * the flag and read the variables themselves. + * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that child direct. Such 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. Non-Node children (curl, git, pnpm) ignore the flag and + * read the variables themselves. * - * @returns names to merge into the child or worker environment, or an empty object when no proxy is active. + * A worker thread is deliberately NOT served here — see the workflow engine, which runs + * model-authored scripts and must not receive a proxy URL that may carry credentials. + * + * @returns names to apply to the child environment, where `undefined` means remove, or an empty + * object when no proxy is active. */ -export function childProxyEnv(): Record { - if (active === undefined || active.source === 'none') return {} - const env: Record = { NODE_USE_ENV_PROXY: '1' } - for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) { - const value = active[field as keyof typeof POLICY_ENV_NAMES] - if (value === undefined || value === '') continue - for (const name of names) env[name] = value - } - return env +export function childProxyEnv(): Readonly> { + if (active === undefined || active.source === 'none' || inheritedProxyEnv === undefined) return {} + return { ...inheritedProxyEnv, NODE_USE_ENV_PROXY: '1' } } diff --git a/packages/net/http-proxy/src/policy.ts b/packages/net/http-proxy/src/policy.ts index 9be82d7f9f..ad87da5bd1 100644 --- a/packages/net/http-proxy/src/policy.ts +++ b/packages/net/http-proxy/src/policy.ts @@ -126,18 +126,31 @@ function readEnv( return undefined } +/** + * What one environment or configuration slot supplied. A rejected slot is distinct from an absent + * one: the user named a proxy for that scheme, so falling back to another scheme's proxy would route + * the request somewhere they never asked for while the diagnostic said it stayed direct. + */ +type ProxyCandidate = + | { readonly kind: 'accepted'; readonly value: string } + | { readonly kind: 'rejected' } + | { readonly kind: 'absent' } + +/** A slot nobody filled. */ +const ABSENT: ProxyCandidate = { kind: 'absent' } + /** * Validate one candidate proxy URL. * * @param candidate - the raw value and the origin to name in a diagnostic. * @param diagnostics - collector the rejection is appended to. - * @returns the candidate when it is a usable `http(s):` proxy URL, otherwise `undefined`. + * @returns the candidate's usability, distinguishing a rejected slot from an empty one. */ function acceptProxyUrl( candidate: { value: string; name: string } | undefined, diagnostics: ProxyDiagnostic[], -): string | undefined { - if (candidate === undefined) return undefined +): ProxyCandidate { + if (candidate === undefined) return ABSENT const parsed = URL.parse(candidate.value) if (parsed === null) { diagnostics.push({ @@ -145,25 +158,39 @@ function acceptProxyUrl( origin: candidate.name, message: `${candidate.name} is not a valid URL; connecting directly`, }) - return undefined + return { kind: 'rejected' } } if (SOCKS_PROTOCOLS.has(parsed.protocol)) { diagnostics.push({ kind: 'socks', origin: candidate.name, - message: `${candidate.name} names a SOCKS proxy, which is not supported; set an http:// or https:// proxy URL instead`, + message: `${candidate.name} names a SOCKS proxy, which is not supported; connecting directly for that scheme — set an http:// or https:// proxy URL instead`, }) - return undefined + return { kind: 'rejected' } } if (!SUPPORTED_PROTOCOLS.has(parsed.protocol)) { diagnostics.push({ kind: 'invalid', origin: candidate.name, - message: `${candidate.name} uses the unsupported ${parsed.protocol}// scheme; set an http:// or https:// proxy URL instead`, + message: `${candidate.name} uses the unsupported ${parsed.protocol}// scheme; connecting directly for that scheme — set an http:// or https:// proxy URL instead`, }) - return undefined + return { kind: 'rejected' } } - return candidate.value + return { kind: 'accepted', value: candidate.value } +} + +/** + * Resolve one scheme's proxy from its own slot, then the fallbacks — but only when the scheme's own + * slot was empty. A rejected slot keeps that scheme direct, so the diagnostic and the route agree. + * + * @param own - what the scheme's own name supplied. + * @param fallbacks - values to try in order when `own` is absent. + * @returns the proxy URL for that scheme, or `undefined` for a direct connection. + */ +function resolveScheme(own: ProxyCandidate, ...fallbacks: (string | undefined)[]): string | undefined { + if (own.kind === 'accepted') return own.value + if (own.kind === 'rejected') return undefined + return fallbacks.find(value => value !== undefined) } /** @@ -252,32 +279,34 @@ export function resolveProxyPolicy( if (config.mode === 'off') return { policy: DIRECT_POLICY, diagnostics } const all = acceptProxyUrl(readEnv(env, 'all_proxy'), diagnostics) - const httpFromEnv = acceptProxyUrl(readEnv(env, 'http_proxy'), diagnostics) ?? all - const httpsFromEnv = acceptProxyUrl(readEnv(env, 'https_proxy'), diagnostics) ?? all - - const httpFromConfig = acceptProxyUrl( + const allValue = all.kind === 'accepted' ? all.value : undefined + const configHttp = acceptProxyUrl( config.httpProxy === undefined ? undefined : { value: config.httpProxy, name: 'config.httpProxy' }, diagnostics, ) - const httpsFromConfig = acceptProxyUrl( + const configHttps = acceptProxyUrl( config.httpsProxy === undefined ? undefined : { value: config.httpsProxy, name: 'config.httpsProxy' }, diagnostics, ) + const configHttpValue = configHttp.kind === 'accepted' ? configHttp.value : undefined + const configHttpsValue = configHttps.kind === 'accepted' ? configHttps.value : undefined - const httpProxy = httpFromEnv ?? httpFromConfig - // HTTPS falls back to the HTTP proxy, so an undefined result here means no layer supplied any - // proxy at all — one check covers both schemes. - const httpsProxy = httpsFromEnv ?? httpsFromConfig ?? httpProxy - if (httpsProxy === undefined) return { policy: DIRECT_POLICY, diagnostics } + const envHttp = acceptProxyUrl(readEnv(env, 'http_proxy'), diagnostics) + const envHttps = acceptProxyUrl(readEnv(env, 'https_proxy'), diagnostics) + const httpProxy = resolveScheme(envHttp, allValue, configHttpValue) + // HTTPS falls back to the HTTP proxy last, matching undici — but never past a value the user named + // for HTTPS and this package refused. + const httpsProxy = resolveScheme(envHttps, allValue, configHttpsValue, httpProxy) + if (httpProxy === undefined && httpsProxy === undefined) return { policy: DIRECT_POLICY, diagnostics } const noProxy = withLoopback(readEnv(env, 'no_proxy')?.value ?? config.noProxy) - const source = httpFromEnv !== undefined || httpsFromEnv !== undefined ? 'env' : 'config' + const fromEnv = envHttp.kind === 'accepted' || envHttps.kind === 'accepted' || all.kind === 'accepted' return { policy: { ...httpProxy === undefined ? {} : { httpProxy }, - httpsProxy, + ...httpsProxy === undefined ? {} : { httpsProxy }, noProxy, - source, + source: fromEnv ? 'env' : 'config', }, diagnostics, } diff --git a/packages/net/http-proxy/tests/install.spec.ts b/packages/net/http-proxy/tests/install.spec.ts index 3050e1f0b5..b068b920eb 100644 --- a/packages/net/http-proxy/tests/install.spec.ts +++ b/packages/net/http-proxy/tests/install.spec.ts @@ -184,34 +184,65 @@ describe('childProxyEnv', () => { } }) - it('carries the resolved policy and the flag that makes a child Node honor it', async () => { + it('hands a child the values the user exported, not this process\'s normalization', async () => { + // A user who set only HTTP_PROXY, plus a SOCKS proxy this package refuses but `curl` uses. + process.env.HTTP_PROXY = proxyUrl + process.env.https_proxy = 'socks5://127.0.0.1:1080' const dispose = await installGlobalProxy(proxyAll('example.com')) try { - expect(childProxyEnv()).toEqual({ - NODE_USE_ENV_PROXY: '1', - http_proxy: proxyUrl, - HTTP_PROXY: proxyUrl, - https_proxy: proxyUrl, - HTTPS_PROXY: proxyUrl, - no_proxy: 'example.com', - NO_PROXY: 'example.com', - }) + const child = childProxyEnv() + // The published policy invented an HTTPS proxy for this process; the child must not see it. + expect(child.https_proxy).toBe('socks5://127.0.0.1:1080') + expect(child.HTTPS_PROXY).toBeUndefined() + expect(child.HTTP_PROXY).toBe(proxyUrl) + expect(child.no_proxy).toBeUndefined() + expect(child.NODE_USE_ENV_PROXY).toBe('1') } finally { await dispose() + delete process.env.HTTP_PROXY + delete process.env.https_proxy } }) +}) - it('omits a scheme the policy leaves direct, so a worker inherits no stale name', async () => { - const dispose = await installGlobalProxy({ httpProxy: proxyUrl, noProxy: '', source: 'env' }) +describe('installGlobalProxy over an existing installation', () => { + it('stops proxying when a direct policy is installed over a proxied one', async () => { + const outer = await installGlobalProxy(proxyAll()) try { - expect(childProxyEnv()).not.toHaveProperty('HTTPS_PROXY') - expect(childProxyEnv()).not.toHaveProperty('NO_PROXY') + await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY') + const off = await installGlobalProxy(DIRECT_POLICY) + try { + // `mode: 'off'` must actually stop proxying, not merely report a direct policy while the + // launcher's agent keeps tunnelling. + await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT') + expect(currentProxyPolicy()).toBe(DIRECT_POLICY) + } finally { + await off() + } + // Disposing the direct policy restores the proxy the launcher installed. + await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY') } finally { - await dispose() + await outer() } }) }) +describe('applyPolicyEnv restoration', () => { + it('restores every name from one snapshot taken before any write', async () => { + process.env.http_proxy = 'http://before.example' + process.env.HTTP_PROXY = 'http://before.example' + const dispose = await installGlobalProxy(proxyAll()) + expect(process.env.HTTP_PROXY).toBe(proxyUrl) + await dispose() + // Reading the uppercase spelling after writing the lowercase one must not restore the value + // just written — the failure Windows's case-folded environment would produce. + expect(process.env.http_proxy).toBe('http://before.example') + expect(process.env.HTTP_PROXY).toBe('http://before.example') + delete process.env.http_proxy + delete process.env.HTTP_PROXY + }) +}) + describe('createNodeHttpAgent', () => { /** Drive a real `node:http` request, which the global dispatcher never reaches. */ function get(target: string, agent: http.Agent): Promise { diff --git a/packages/net/http-proxy/tests/matcher-parity.spec.ts b/packages/net/http-proxy/tests/matcher-parity.spec.ts new file mode 100644 index 0000000000..9156a7f38c --- /dev/null +++ b/packages/net/http-proxy/tests/matcher-parity.spec.ts @@ -0,0 +1,63 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installGlobalProxy, proxyForUrl, type ProxyPolicy } from '../src/index.ts' + +/** + * `proxyForUrl` and the installed `EnvHttpProxyAgent` are two matchers over one bypass list. They are + * fed the same values, but their parsers are independent: a form they judge differently would route a + * plain `fetch` one way and `web_fetch` the other. These cases pin the forms in the documented + * vocabulary against the agent's real behavior. + */ +const CASES: readonly { readonly noProxy: string; readonly path: string; readonly bypassed: boolean }[] = [ + { noProxy: '', path: '/plain', bypassed: false }, + { noProxy: 'probe.invalid', path: '/exact', bypassed: true }, + { noProxy: '.probe.invalid', path: '/dot-suffix', bypassed: true }, + { noProxy: '*.probe.invalid', path: '/star-suffix', bypassed: true }, + { noProxy: 'other.invalid', path: '/miss', bypassed: false }, + { noProxy: '*', path: '/all', bypassed: true }, + { noProxy: 'probe.invalid:80', path: '/with-default-port', bypassed: true }, + { noProxy: 'probe.invalid:8443', path: '/wrong-port', bypassed: false }, + { noProxy: 'a.invalid, probe.invalid', path: '/comma-list', bypassed: true }, +] + +let seen: string[] = [] +let proxy: Server +let proxyUrl: string + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(request.url ?? '') + response.writeHead(200, { 'content-type': 'text/plain' }) + response.end('VIA-PROXY') + }) + const address = await new Promise((resolve) => { + proxy.listen(0, '127.0.0.1', () => { resolve(proxy.address() as AddressInfo) }) + }) + proxyUrl = `http://127.0.0.1:${String(address.port)}` +}) + +afterAll(async () => { + await new Promise((resolve) => { proxy.close(() => { resolve() }) }) +}) + +function policy(noProxy: string): ProxyPolicy { + return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy, source: 'env' } +} + +describe('bypass matcher parity', () => { + it.each(CASES)('agrees on $noProxy for $path', async ({ noProxy, path, bypassed }) => { + seen = [] + const url = new URL(`http://probe.invalid${path}`) + const dispose = await installGlobalProxy(policy(noProxy)) + try { + // A bypassed target has no route here, so the fetch fails; a proxied one reaches the recorder. + await fetch(url).then(response => response.text()).catch(() => undefined) + const agentProxied = seen.length > 0 + expect({ ours: proxyForUrl(policy(noProxy), url) !== undefined, agent: agentProxied }) + .toEqual({ ours: !bypassed, agent: !bypassed }) + } finally { + await dispose() + } + }) +}) diff --git a/packages/net/http-proxy/tests/policy.spec.ts b/packages/net/http-proxy/tests/policy.spec.ts index 955346b838..b76da665fb 100644 --- a/packages/net/http-proxy/tests/policy.spec.ts +++ b/packages/net/http-proxy/tests/policy.spec.ts @@ -67,6 +67,22 @@ describe('resolveProxyPolicy', () => { expect(policy.noProxy).toBe('localhost,127.0.0.1,::1,[::1]') }) + it('keeps a scheme direct when its own value was refused, rather than falling back', () => { + const { policy, diagnostics } = resolveProxyPolicy(env({ HTTPS_PROXY: 'socks5://127.0.0.1:1080', HTTP_PROXY: PROXY })) + expect(policy.httpProxy).toBe(PROXY) + // The diagnostic says HTTPS connects directly; the route must agree rather than borrowing the + // HTTP proxy the user never named for HTTPS. + expect(policy.httpsProxy).toBeUndefined() + expect(proxyForUrl(policy, new URL('https://example.com/'))).toBeUndefined() + expect(diagnostics[0]?.message).toMatch(/connecting directly for that scheme/) + }) + + it('keeps a scheme direct when its own value was malformed, past ALL_PROXY too', () => { + const { policy } = resolveProxyPolicy(env({ HTTPS_PROXY: 'not a url', ALL_PROXY: PROXY })) + expect(policy.httpProxy).toBe(PROXY) + expect(policy.httpsProxy).toBeUndefined() + }) + it('reports a SOCKS proxy instead of silently ignoring it', () => { const { policy, diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'socks5://127.0.0.1:7890' })) expect(policy).toEqual(DIRECT_POLICY) @@ -95,6 +111,13 @@ describe('resolveProxyPolicy', () => { expect(policy.source).toBe('config') }) + it('takes each scheme from its own configured field', () => { + const { policy } = resolveProxyPolicy(env({}), { httpProxy: PROXY, httpsProxy: OTHER }) + expect(policy.httpProxy).toBe(PROXY) + expect(policy.httpsProxy).toBe(OTHER) + expect(policy.source).toBe('config') + }) + it('lets the environment outrank configuration', () => { const { policy } = resolveProxyPolicy(env({ HTTP_PROXY: PROXY }), { httpProxy: OTHER }) expect(policy.httpProxy).toBe(PROXY) diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index ed17ce93b8..2c3afde2df 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -217,9 +217,11 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { // The one added default is the agent. On Node this exporter posts through `node:http`, // which undici's global dispatcher does not reach, so telemetry would be the one egress // that ignores a configured proxy. A composition supplying its own `httpAgentOptions` - // keeps it. + // keeps it; one supplying only `keepAlive` still decides it, because the SDK stops + // interpreting that field the moment an agent factory is present. exporter: new OTLPLogExporter({ - httpAgentOptions: (protocol: string) => createNodeHttpAgent(protocol, { keepAlive: true }), + httpAgentOptions: (protocol: string) => + createNodeHttpAgent(protocol, { keepAlive: config.exporter?.keepAlive ?? true }), ...config.exporter, }), }), diff --git a/packages/session/session-telemetry-otel/tests/egress.spec.ts b/packages/session/session-telemetry-otel/tests/egress.spec.ts index 6519e461dd..df43b8873e 100644 --- a/packages/session/session-telemetry-otel/tests/egress.spec.ts +++ b/packages/session/session-telemetry-otel/tests/egress.spec.ts @@ -51,12 +51,12 @@ afterAll(() => { }) /** Mount the shipping backend against an unresolvable collector and let it try to export. */ -async function exportThroughBackend(): Promise { +async function exportThroughBackend(host: string, exporter: Record = {}): Promise { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, - exporter: { url: 'http://otel-probe.invalid/v1/logs' }, + exporter: { url: `http://${host}/v1/logs`, ...exporter }, }) const session = ctx.sessions.create(SessionId('egress'), { meta: { cwd: '/tmp/e' } }) session.append('turn/start', { turn: 1 }) @@ -64,8 +64,62 @@ async function exportThroughBackend(): Promise { await fiber.dispose() } + +/** + * Whether this runtime's `http.Agent` honors `proxyEnv`, which is how the OTLP exporter reaches a + * proxy. Added in Node 24.5 and backported to 22.21; the engines range admits 22.19, 22.20, and + * 24.0–24.4, where telemetry stays direct. + */ +function supportsAgentProxyEnv(): boolean { + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number) + return (major === 24 && minor >= 5) || major > 24 || (major === 22 && minor >= 21) +} + describe('session-telemetry-otel egress', () => { it('exports through the proxy', async () => { - expect((await observe(exportThroughBackend)).join('|')).toContain('otel-probe.invalid') + const observed = (await observe(() => exportThroughBackend('otel-proxied.invalid'))).join('|') + // An older runtime ignores the unknown `proxyEnv` option and keeps telemetry direct — the + // documented seam, asserted rather than left to chance. + if (supportsAgentProxyEnv()) expect(observed).toContain('otel-proxied.invalid') + else expect(observed).toBe('') + }) + + it('reaches no proxy without the agent this package supplies — the gap it closes', async () => { + const observed = await observe(() => exportThroughBackend('otel-direct.invalid', { + httpAgentOptions: async (protocol: string) => { + const core = protocol === 'https:' ? await import('node:https') : await import('node:http') + return new core.Agent({ keepAlive: false }) + }, + })) + // The SDK's own default agent is this shape. Restoring it must fail loudly here rather than + // silently un-proxying telemetry on an upgrade. A per-test host keeps a late-arriving export + // from an earlier case out of this assertion. + expect(observed.join('|')).not.toContain('otel-direct.invalid') + }) +}) + +describe('session-telemetry-otel exporter passthrough', () => { + it('lets a composition keep its own agent factory, which then owns the routing', async () => { + let called = 0 + await observe(() => exportThroughBackend('otel-passthrough.invalid', { + httpAgentOptions: async () => { + called++ + const core = await import('node:http') + return new core.Agent({ keepAlive: false }) + }, + })) + // The exporter option is documented as verbatim passthrough: a composition that supplies its own + // factory owns the transport, and this package's default must step aside. + expect(called).toBeGreaterThan(0) + }) + + it('honors exporter.keepAlive on the agent this package supplies', async () => { + const { createNodeHttpAgent } = await import('@deepseek-ai/dsh-http-proxy') + const agent = await createNodeHttpAgent('http:', { keepAlive: false }) + try { + expect((agent as unknown as { options: { keepAlive?: boolean } }).options.keepAlive).toBe(false) + } finally { + agent.destroy() + } }) }) diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 55621b268b..f7960eb269 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -39,6 +39,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-http-proxy": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^" } } diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 34c08e9875..c028d2631e 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -67,8 +67,14 @@ export function scrubbedParentEnv(): Record { if (value !== undefined && !SENSITIVE_ENV_PATTERN.test(key) && !key.toUpperCase().startsWith(DSH_ENV_PREFIX)) env[key] = value } // A child Node ignores the inherited proxy variables unless the flag this adds is set, so an MCP - // stdio server or subagent CLI would connect directly while its parent proxies. - return { ...env, ...childProxyEnv() } + // stdio server or subagent CLI would connect directly while its parent proxies. The same overlay + // restores each proxy name to what the user exported, undoing this process's own normalization — + // `undefined` removes a name the user never set. + for (const [name, value] of Object.entries(childProxyEnv())) { + if (value === undefined) Reflect.deleteProperty(env, name) + else env[name] = value + } + return env } declare module '@deepseek-ai/cordis' { diff --git a/packages/subprocess/subprocess/tests/egress.spec.ts b/packages/subprocess/subprocess/tests/egress.spec.ts index c7395a377e..6e4eb06c14 100644 --- a/packages/subprocess/subprocess/tests/egress.spec.ts +++ b/packages/subprocess/subprocess/tests/egress.spec.ts @@ -1,71 +1,119 @@ import { createServer, type Server } from 'node:http' -import type { AddressInfo } from 'node:net' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' - -let seen: string[] = [] -let proxy: Server -let proxyUrl: string - -beforeAll(async () => { - proxy = createServer((request, response) => { - seen.push(`REQ ${request.url ?? ''}`) - response.writeHead(502); response.end('fake-proxy') - }) - proxy.on('connect', (request, socket) => { - seen.push(`CONNECT ${request.url ?? ''}`) - socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() - }) - const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) - proxyUrl = `http://127.0.0.1:${String(a.port)}` -}) -afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) - -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } -} -async function observe(run: () => Promise): Promise { - seen = [] - const dispose = await installGlobalProxy(policy()) - try { await run().catch(() => undefined) } finally { await dispose() } - return seen -} import { spawn } from 'node:child_process' +import type { AddressInfo } from 'node:net' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { + PROXY_ENV_NAMES, + installGlobalProxy, + resolveProxyPolicy, +} from '@deepseek-ai/dsh-http-proxy' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '../src/index.ts' -/** Run a child Node that fetches, using exactly the environment every harness spawner builds. */ -function childFetch(target: string, env: Record): Promise { - return new Promise((resolve) => { - const child = spawn(process.execPath, ['-e', `fetch(${JSON.stringify(target)}).then(r=>r.text()).then(t=>console.log(t)).catch(e=>console.log('ERR'+String(e.cause?.code)))`], - { env, stdio: ['ignore', 'pipe', 'ignore'] }) - let out = '' - child.stdout.on('data', (c: Buffer) => { out += c.toString() }) - child.on('close', () => { resolve(out.trim()) }) - }) -} - - /** - * Whether this runtime honors `NODE_USE_ENV_PROXY`, which is how a separate Node execution context - * receives the policy. Added in Node 24.0 and backported to 22.21; the engines range admits 22.19 - * and 22.20, where such a context stays direct. + * Whether this runtime honors `NODE_USE_ENV_PROXY`, which is how a child Node receives the policy. + * Added in Node 24.0 and backported to 22.21; the engines range admits 22.19 and 22.20, where a + * child stays direct. */ function supportsEnvProxy(): boolean { const [major = 0, minor = 0] = process.versions.node.split('.').map(Number) return major >= 24 || (major === 22 && minor >= 21) } +let seen: string[] = [] +let proxy: Server +let proxyUrl: string +let saved: Record = {} + +beforeAll(async () => { + proxy = createServer((request, response) => { + seen.push(request.url ?? '') + response.writeHead(200) + response.end('VIA-PROXY') + }) + // Node's own proxy support may tunnel rather than send an absolute-form request; record either. + proxy.on('connect', (request, socket) => { + seen.push(request.url ?? '') + socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n') + socket.end() + }) + const address = await new Promise((resolve) => { + proxy.listen(0, '127.0.0.1', () => { resolve(proxy.address() as AddressInfo) }) + }) + proxyUrl = `http://127.0.0.1:${String(address.port)}` +}) + +afterAll(async () => { + await new Promise((resolve) => { proxy.close(() => { resolve() }) }) +}) + +beforeEach(() => { + seen = [] + saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]])) + for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name) +}) + +afterEach(() => { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } +}) + +/** Run a child Node that fetches, using exactly the environment every harness spawner builds. */ +function childFetch(target: string, env: Record): Promise { + return new Promise((resolve) => { + const child = spawn( + process.execPath, + ['-e', `fetch(${JSON.stringify(target)}).then(r=>r.text()).then(t=>console.log(t)).catch(e=>console.log('ERR'+String(e.cause?.code)))`], + { env, stdio: ['ignore', 'pipe', 'ignore'] }, + ) + let out = '' + child.stdout.on('data', (chunk: Buffer) => { out += chunk.toString() }) + child.on('close', () => { resolve(out.trim()) }) + }) +} + describe('child process egress', () => { - it('a child Node honors the parent policy through scrubbedParentEnv', async () => { + it('a child Node honors the proxy the user exported', async () => { + // The user's own export is what a child inherits, so the scenario starts from one. + process.env.HTTP_PROXY = proxyUrl + const { policy } = resolveProxyPolicy( + createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]), + ) + const dispose = await installGlobalProxy(policy) let childEnv: Record = {} - const observed = await observe(async () => { + try { childEnv = scrubbedParentEnv() await childFetch('http://child-probe.invalid/x', childEnv) - }) + } finally { + await dispose() + } expect(childEnv.NODE_USE_ENV_PROXY).toBe('1') + expect(childEnv.HTTP_PROXY).toBe(proxyUrl) // The flag is what a child Node acts on; an older runtime ignores it and stays direct, which is // the documented seam rather than a defect. - if (supportsEnvProxy()) expect(observed.join('|')).toContain('child-probe.invalid') - else expect(observed).toEqual([]) + if (supportsEnvProxy()) expect(seen.join('|')).toContain('child-probe.invalid') + else expect(seen).toEqual([]) + }) + + it('does not invent a proxy name the user never exported', async () => { + process.env.HTTP_PROXY = proxyUrl + const { policy } = resolveProxyPolicy( + createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]), + ) + const dispose = await installGlobalProxy(policy) + try { + // This process resolved an HTTPS proxy by falling back to the HTTP one; a child must not see + // a name the user never set, because `curl` performs no such fallback of its own. + expect(process.env.HTTPS_PROXY).toBe(proxyUrl) + expect(scrubbedParentEnv().HTTPS_PROXY).toBeUndefined() + } finally { + await dispose() + } + }) + + it('adds nothing when no proxy is active', () => { + expect(scrubbedParentEnv().NODE_USE_ENV_PROXY).toBeUndefined() }) }) diff --git a/packages/subprocess/subprocess/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json index d7a22325fc..d5517e666f 100644 --- a/packages/subprocess/subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../net/http-proxy" + }, + { + "path": "../../util/launch-environment" } ] } diff --git a/packages/web/web-fetch-http/src/network.ts b/packages/web/web-fetch-http/src/network.ts index 5c69e8e013..dd7d66ddca 100644 --- a/packages/web/web-fetch-http/src/network.ts +++ b/packages/web/web-fetch-http/src/network.ts @@ -225,6 +225,7 @@ async function requestWith( const { fetch } = await import('undici') const dispatcher = await createDispatcher(url, options) try { + // proxy-exempt: the dispatcher is createDispatcher's, which already applied the active policy. const response = await fetch(url, { method: 'GET', redirect: 'manual', headers, signal, dispatcher }) return { response, close: async () => { await dispatcher.close() } } } catch (error: unknown) { diff --git a/packages/workflow/workflow-worker-thread/src/host.ts b/packages/workflow/workflow-worker-thread/src/host.ts index 22623f5e54..adb0d1503f 100644 --- a/packages/workflow/workflow-worker-thread/src/host.ts +++ b/packages/workflow/workflow-worker-thread/src/host.ts @@ -8,7 +8,6 @@ import { tmpdir } from 'node:os' import { Worker } from 'node:worker_threads' -import { childProxyEnv } from '@deepseek-ai/dsh-http-proxy' import type { WorkerOptions } from 'node:worker_threads' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' @@ -31,9 +30,11 @@ interface ChildRecord { } /** - * The scrubbed worker environment: no ambient credentials, no loader flags, plus the active proxy - * policy — a worker thread does not inherit the host's global dispatcher, so this is the only way - * its requests reach the same proxy the host uses. + * The scrubbed worker environment: no ambient credentials, no loader flags, and deliberately no + * proxy policy. A worker thread does not inherit the host's global dispatcher, so a workflow's own + * requests go direct — the alternative is handing the worker a proxy URL that may carry + * `user:password`, and this worker executes the model-authored script body. That is the same + * containment the code runtime keeps, and `docs/defensive-patterns.md` requires it. * Windows derives `os.tmpdir()` from `TMP`/`TEMP` and falls back to the * literal relative path `undefined\temp` when the environment is empty, so * tsx's transform cache would land in a cwd-relative `undefined/temp` @@ -49,11 +50,7 @@ export function workerSpawnEnv( platform: NodeJS.Platform = process.platform, tsconfigPath?: string, ): NodeJS.ProcessEnv { - // A worker thread gets its own globalThis and therefore does NOT inherit the host's undici global - // dispatcher, so a workflow that fetches would connect directly while its host proxies. This - // near-empty environment is the only channel it has: the proxy names plus Node's own opt-in flag - // reach the worker's pre-execution setup, which runs per thread. - const env: NodeJS.ProcessEnv = { ...childProxyEnv() } + const env: NodeJS.ProcessEnv = {} if (platform === 'win32') { const tmp = tmpdir() env.TMP = tmp diff --git a/packages/workflow/workflow-worker-thread/tests/egress.spec.ts b/packages/workflow/workflow-worker-thread/tests/egress.spec.ts index 05b1cd9cb0..b8a34a4fbe 100644 --- a/packages/workflow/workflow-worker-thread/tests/egress.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/egress.spec.ts @@ -1,64 +1,31 @@ -import { createServer, type Server } from 'node:http' -import type { AddressInfo } from 'node:net' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' - -let seen: string[] = [] -let proxy: Server -let proxyUrl: string - -beforeAll(async () => { - proxy = createServer((request, response) => { - seen.push(`REQ ${request.url ?? ''}`) - response.writeHead(502); response.end('fake-proxy') - }) - proxy.on('connect', (request, socket) => { - seen.push(`CONNECT ${request.url ?? ''}`) - socket.write('HTTP/1.1 502 Bad Gateway\r\n\r\n'); socket.end() - }) - const a = await new Promise((r) => { proxy.listen(0, '127.0.0.1', () => { r(proxy.address() as AddressInfo) }) }) - proxyUrl = `http://127.0.0.1:${String(a.port)}` -}) -afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) - -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } -} -async function observe(run: () => Promise): Promise { - seen = [] - const dispose = await installGlobalProxy(policy()) - try { await run().catch(() => undefined) } finally { await dispose() } - return seen -} -import { Worker } from 'node:worker_threads' -import { once } from 'node:events' +import { describe, expect, it } from 'vitest' +import { PROXY_ENV_NAMES, installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' import { workerSpawnEnv } from '../src/host.ts' - -/** - * Whether this runtime honors `NODE_USE_ENV_PROXY`, which is how a separate Node execution context - * receives the policy. Added in Node 24.0 and backported to 22.21; the engines range admits 22.19 - * and 22.20, where such a context stays direct. - */ -function supportsEnvProxy(): boolean { - const [major = 0, minor = 0] = process.versions.node.split('.').map(Number) - return major >= 24 || (major === 22 && minor >= 21) +/** A policy carrying credentials, the shape that must never reach model-authored code. */ +const CREDENTIALED: ProxyPolicy = { + httpProxy: 'http://alice:s3cret@proxy.example:8080', + httpsProxy: 'http://alice:s3cret@proxy.example:8080', + noProxy: '', + source: 'env', } -describe('worker thread egress', () => { - it('a worker honors the host policy through workerSpawnEnv', async () => { - const observed = await observe(async () => { - const worker = new Worker( - `import { parentPort, workerData } from 'node:worker_threads' - let out; try { out = await (await fetch(workerData.u)).text() } catch (e) { out = 'ERR' + String(e.cause?.code) } - parentPort.postMessage(out)`, - { eval: true, workerData: { u: 'http://worker-probe.invalid/x' }, env: workerSpawnEnv(), execArgv: [] }, - ) - await once(worker, 'message') - await worker.terminate() - }) - // Same seam as a spawned child: the worker acts on the flag its environment carries. - if (supportsEnvProxy()) expect(observed.join('|')).toContain('worker-probe.invalid') - else expect(observed).toEqual([]) +describe('workflow worker egress', () => { + it('hands the worker no proxy configuration, credentialed or not', async () => { + const dispose = await installGlobalProxy(CREDENTIALED) + try { + const env = workerSpawnEnv() + // The worker executes the model-authored script body, so a proxy URL that may carry + // `user:password` must not be readable from its environment. + for (const name of PROXY_ENV_NAMES) expect(env).not.toHaveProperty(name) + expect(env).not.toHaveProperty('NODE_USE_ENV_PROXY') + expect(JSON.stringify(env)).not.toContain('s3cret') + } finally { + await dispose() + } + }) + + it('still carries the platform temp path the worker needs on Windows', () => { + expect(workerSpawnEnv('win32')).toHaveProperty('TMP') }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c3ccfce670..38c839853e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4592,6 +4592,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment '@deepseek-ai/dsh-loader-smoke': specifier: workspace:^ version: link:../../test-support/loader-smoke @@ -9198,6 +9201,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment packages/subprocess/subprocess-local: dependencies: diff --git a/scripts/verify-no-bare-dispatcher.spec.ts b/scripts/verify-no-bare-dispatcher.spec.ts index eebbc533d7..3bc67ad424 100644 --- a/scripts/verify-no-bare-dispatcher.spec.ts +++ b/scripts/verify-no-bare-dispatcher.spec.ts @@ -10,23 +10,28 @@ function reasons(source: string, file = FILE): string[] { describe('bare dispatcher check', () => { it('rejects the shape that silently bypassed the proxy before this rule existed', () => { expect(reasons(` + import { Agent } from 'undici' const dispatcher = new Agent({ connect: { lookup } }) const response = await fetch(url, { dispatcher }) - `)).toEqual(['constructs an undici agent']) + `)).toEqual(['constructs an undici agent', 'passes an explicit \`dispatcher\`']) }) it('rejects an explicit dispatcher option however the agent was obtained', () => { expect(reasons(" const response = await fetch(url, { method: 'GET', dispatcher: pooled })")) - .toEqual(['passes an explicit `dispatcher`']) + .toEqual(['passes an explicit \`dispatcher\`']) }) it('rejects a namespaced construction', () => { - expect(reasons(' const agent = new undici.ProxyAgent(uri)')).toEqual(['constructs an undici agent']) + expect(reasons(` + import * as undici from 'undici' + const agent = new undici.ProxyAgent(uri) + `)).toEqual(['constructs an undici agent']) }) it('reports the offending line number and text', () => { - expect(findDispatcherViolations(FILE, 'const a = 1\nconst b = new Agent({})')).toEqual([ - { file: FILE, line: 2, what: 'constructs an undici agent', text: 'const b = new Agent({})' }, + const source = "import { Agent } from 'undici'\nconst a = 1\nconst b = new Agent({})" + expect(findDispatcherViolations(FILE, source)).toEqual([ + { file: FILE, line: 3, what: 'constructs an undici agent', text: 'const b = new Agent({})' }, ]) }) @@ -34,17 +39,54 @@ describe('bare dispatcher check', () => { expect(reasons(' const dispatcher = await createDispatcher(url, options)')).toEqual([]) }) + it('rejects the shorthand form a line-wise regex misses', () => { + expect(reasons(` + import { Agent } from 'undici' + const dispatcher = pool + const response = await fetch(url, { method: 'GET', dispatcher }) + `)).toEqual(['passes an explicit \`dispatcher\`']) + }) + + it('rejects a quoted dispatcher key', () => { + expect(reasons(" await fetch(url, { 'dispatcher': pooled })")) + .toEqual(['passes an explicit \`dispatcher\`']) + }) + + it('rejects construction through an import alias', () => { + expect(reasons(` + import { Agent as CustomAgent } from 'undici' + const agent = new CustomAgent({}) + `)).toEqual(['constructs an undici agent']) + }) + + it('accepts an unrelated class that happens to be named Agent', () => { + expect(reasons(` + import { Agent } from './our-own-agent.ts' + const agent = new Agent({}) + `)).toEqual([]) + }) + + it('accepts an exemption annotated on the line above, where a long line puts it', () => { + expect(reasons(` + import { Agent } from 'undici' + // proxy-exempt: the dispatcher already applied the active policy. + const response = await fetch(url, { method: 'GET', headers, dispatcher }) + `)).toEqual([]) + }) + it('accepts an annotated exemption', () => { - expect(reasons(' const agent = new Agent({}) // proxy-exempt: loopback transport for the local test server')) - .toEqual([]) + expect(reasons(` + import { Agent } from 'undici' + const agent = new Agent({}) // proxy-exempt: loopback transport for the local test server + `)).toEqual([]) }) it('exempts the package that owns dispatcher construction', () => { - expect(reasons('const agent = new EnvHttpProxyAgent()', `${DISPATCHER_OWNER}src/install.ts`)).toEqual([]) + expect(reasons("import { EnvHttpProxyAgent } from 'undici'\nconst agent = new EnvHttpProxyAgent()", `${DISPATCHER_OWNER}src/install.ts`)).toEqual([]) }) it('normalizes native separators before exempting the owning package', () => { - expect(reasons('const agent = new Agent({})', DISPATCHER_OWNER.replaceAll('/', '\\') + 'src\\install.ts')).toEqual([]) + expect(reasons("import { Agent } from 'undici'\nconst agent = new Agent({})", DISPATCHER_OWNER.replaceAll('/', '\\') + 'src\\install.ts')).toEqual([]) }) it('passes on the current tree', () => { diff --git a/scripts/verify-no-bare-dispatcher.ts b/scripts/verify-no-bare-dispatcher.ts index 422e37daf2..16ca7f0477 100644 --- a/scripts/verify-no-bare-dispatcher.ts +++ b/scripts/verify-no-bare-dispatcher.ts @@ -8,67 +8,154 @@ * agent silently bypassed every proxy. * * `createDispatcher()` from that package is the sanctioned way to get agent options AND the policy. + * + * Discovery is syntax-aware, as `scripts/AGENTS.md` requires: a line-wise regex misses the + * `{ dispatcher }` shorthand and a `new Alias(...)` whose import renamed `Agent`, and both bypass the + * proxy exactly as the spelled-out forms do. */ import { globSync, readFileSync } from 'node:fs' -import { resolve } from 'node:path' +import { relative, resolve } from 'node:path' +import ts from 'typescript' const root = resolve(import.meta.dirname, '..') /** The package that owns dispatcher construction; its own agents are the implementation. */ export const DISPATCHER_OWNER = 'packages/net/http-proxy/' -/** A line carrying this marker states why it is exempt and is left alone. */ +/** + * A comment carrying this marker states why the construction or option is exempt. It counts on the + * offending line or the line directly above it, because a syntax-aware match anchors on the property + * or `new` expression rather than the statement, and the explanation belongs above a long line. + */ export const ALLOW_MARKER = 'proxy-exempt:' -/** Constructing an undici agent, or naming a `dispatcher` option, outside the owning package. */ -const PATTERNS: readonly { readonly probe: RegExp; readonly what: string }[] = [ - { probe: /\bnew\s+(?:undici\.)?(?:Agent|ProxyAgent|EnvHttpProxyAgent)\s*\(/, what: 'constructs an undici agent' }, - { probe: /\bdispatcher\s*:/, what: 'passes an explicit `dispatcher`' }, -] +/** Undici agent classes whose construction selects a transport, under any local name. */ +const AGENT_EXPORTS = new Set(['Agent', 'ProxyAgent', 'EnvHttpProxyAgent']) -/** One source line that would bypass the configured proxy. */ +/** The module those classes must come from; a same-named class from elsewhere selects no transport. */ +const AGENT_MODULE = 'undici' + +/** The request option that overrides the global dispatcher, however it is written. */ +const DISPATCHER_PROPERTY = 'dispatcher' + +/** One source position that would bypass the configured proxy. */ export interface DispatcherViolation { /** Repository-relative path, in POSIX separators. */ readonly file: string /** One-based line number. */ readonly line: number - /** Which rule the line broke. */ + /** Which rule the position broke. */ readonly what: string - /** The offending line, trimmed. */ + /** The offending source text, trimmed. */ readonly text: string } /** - * Find every bare-dispatcher line in one source file. + * Local names bound to an undici agent class, including `import { Agent as X }` renames and a + * namespace import's own name so `undici.Agent` is recognised too. + * + * @param source - the parsed file. + * @returns agent identifiers and namespace identifiers bound in this file. + */ +function agentBindings(source: ts.SourceFile): { agents: Set; namespaces: Set } { + const agents = new Set() + const namespaces = new Set() + for (const statement of source.statements) { + if (!ts.isImportDeclaration(statement)) continue + if (!ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== AGENT_MODULE) continue + const bindings = statement.importClause?.namedBindings + if (bindings === undefined) continue + if (ts.isNamespaceImport(bindings)) { + namespaces.add(bindings.name.text) + continue + } + for (const element of bindings.elements) { + const imported = (element.propertyName ?? element.name).text + if (AGENT_EXPORTS.has(imported)) agents.add(element.name.text) + } + } + return { agents, namespaces } +} + +/** + * Whether an expression names an undici agent class: a bound identifier, or a `.Agent` + * property access. + * + * @param expression - the `new` expression's callee. + * @param bound - identifiers this file bound to an agent class or a namespace. + * @returns true when constructing it selects a transport. + */ +function namesAgent(expression: ts.Expression, bound: ReturnType): boolean { + if (ts.isIdentifier(expression)) return bound.agents.has(expression.text) + if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression)) { + return bound.namespaces.has(expression.expression.text) && AGENT_EXPORTS.has(expression.name.text) + } + return false +} + +/** + * Whether an object literal member supplies `dispatcher`, covering `dispatcher: x`, the `{ dispatcher }` + * shorthand, and `{ 'dispatcher': x }`. + * + * @param member - one object-literal element. + * @returns true when the member names the dispatcher option. + */ +function suppliesDispatcher(member: ts.ObjectLiteralElementLike): boolean { + if (ts.isShorthandPropertyAssignment(member)) return member.name.text === DISPATCHER_PROPERTY + if (!ts.isPropertyAssignment(member)) return false + const name = member.name + if (ts.isIdentifier(name) || ts.isStringLiteral(name)) return name.text === DISPATCHER_PROPERTY + return false +} + +/** + * Find every bare-dispatcher position in one source file. * * @param file - repository-relative path, used to exempt the owning package and to report location. * @param sourceText - the file's contents. - * @returns one violation per offending line, in file order. + * @returns one violation per offending position, in source order. */ export function findDispatcherViolations(file: string, sourceText: string): DispatcherViolation[] { const posix = file.replaceAll('\\', '/') if (posix.startsWith(DISPATCHER_OWNER)) return [] + const source = ts.createSourceFile(posix, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) + const bound = agentBindings(source) + const lines = sourceText.split('\n') const violations: DispatcherViolation[] = [] - sourceText.split('\n').forEach((text, index) => { - if (text.includes(ALLOW_MARKER)) return - for (const { probe, what } of PATTERNS) { - if (probe.test(text)) violations.push({ file: posix, line: index + 1, what, text: text.trim() }) + + const record = (node: ts.Node, what: string): void => { + const line = source.getLineAndCharacterOfPosition(node.getStart(source)).line + const exempt = [lines[line], lines[line - 1]].some(text => text?.includes(ALLOW_MARKER) === true) + if (exempt) return + violations.push({ file: posix, line: line + 1, what, text: (lines[line] ?? '').trim() }) + } + + const visit = (node: ts.Node): void => { + if (ts.isNewExpression(node) && namesAgent(node.expression, bound)) { + record(node, 'constructs an undici agent') } - }) + if (ts.isObjectLiteralExpression(node) && node.properties.some(suppliesDispatcher)) { + record(node.properties.find(suppliesDispatcher) as ts.Node, 'passes an explicit `dispatcher`') + } + ts.forEachChild(node, visit) + } + ts.forEachChild(source, visit) return violations } /** * Scan every package and app source file in the repository. * - * @returns every violation found, grouped by the order the files were scanned. + * @returns every violation found, in scan order. + * @throws when the corpus is empty, which would make the gate pass by scanning nothing. */ export function scanRepository(): DispatcherViolation[] { const files = [ ...globSync('packages/*/*/src/**/*.ts', { cwd: root }), ...globSync('apps/*/src/**/*.ts', { cwd: root }), ] + if (files.length === 0) throw new Error('verify-no-bare-dispatcher: scanned an empty corpus; the globs no longer match.') return files.flatMap(file => findDispatcherViolations(file, readFileSync(resolve(root, file), 'utf8'))) } @@ -80,7 +167,7 @@ function main(): void { } console.error('verify-no-bare-dispatcher: a dispatcher built outside @deepseek-ai/dsh-http-proxy bypasses the configured proxy.\n') for (const violation of violations) { - console.error(` ${violation.file}:${String(violation.line)} ${violation.what}`) + console.error(` ${relative('.', violation.file)}:${String(violation.line)} ${violation.what}`) console.error(` ${violation.text}`) } console.error('\nUse `createDispatcher(url, options)` from @deepseek-ai/dsh-http-proxy, or annotate the line') From 52e39cd783315a85938dc9ac134a7a9e381d6ed6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 17:11:22 +0800 Subject: [PATCH 007/131] test(net): clear the ambient proxy before asserting what a child inherits The case builds a user environment and asserts a child receives it verbatim, so a runner that exports its own proxy supplied half the "user" values and decided the assertion. --- packages/net/http-proxy/tests/install.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/net/http-proxy/tests/install.spec.ts b/packages/net/http-proxy/tests/install.spec.ts index b068b920eb..28ffdf2af0 100644 --- a/packages/net/http-proxy/tests/install.spec.ts +++ b/packages/net/http-proxy/tests/install.spec.ts @@ -12,7 +12,7 @@ import { installGlobalProxy, proxyUrlFor, } from '../src/install.ts' -import { DIRECT_POLICY, type ProxyPolicy } from '../src/policy.ts' +import { DIRECT_POLICY, PROXY_ENV_NAMES, type ProxyPolicy } from '../src/policy.ts' /** Absolute-form request targets the fake proxy received; a populated entry proves a request was tunnelled. */ let proxied: string[] = [] @@ -185,6 +185,10 @@ describe('childProxyEnv', () => { }) it('hands a child the values the user exported, not this process\'s normalization', async () => { + // Start from a known environment: a CI runner or developer machine may export its own proxy, + // which would otherwise appear as the "user's" value and decide this assertion. + const saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]])) + for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name) // A user who set only HTTP_PROXY, plus a SOCKS proxy this package refuses but `curl` uses. process.env.HTTP_PROXY = proxyUrl process.env.https_proxy = 'socks5://127.0.0.1:1080' @@ -199,8 +203,10 @@ describe('childProxyEnv', () => { expect(child.NODE_USE_ENV_PROXY).toBe('1') } finally { await dispose() - delete process.env.HTTP_PROXY - delete process.env.https_proxy + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } } }) }) From cfc9b3bdef631226052b46e3c852ead0348b6dd9 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 28 Aug 2026 14:57:51 +0800 Subject: [PATCH 008/131] fix(net): route by the policy, and give a child the routing its parent has MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second review pass on the outbound proxy work. The installed dispatcher was undici's EnvHttpProxyAgent, which reuses the HTTP proxy for `https:` whenever no HTTPS proxy is present. That is exactly the state this package resolves after refusing a SOCKS or malformed URL the user named for `https:`, so the scheme the diagnostic reported as direct was tunnelled anyway. The dispatcher is now an Agent whose per-origin factory calls `proxyForUrl`, so routing and `proxyForUrl` cannot disagree by parsing the same list twice. `childProxyEnv` returned only the names the user exported, which left a child Node direct whenever the proxy came from `ALL_PROXY` or from cordis.yml — Node's `NODE_USE_ENV_PROXY` reads neither — and stripped the merged loopback bypass so the child sent its own localhost traffic to the proxy. A scheme the user named in either casing still reaches the child exactly as written; one they named in neither now carries the resolved value, and the bypass list is always the merged one. A nested install (the plugin mounted over the launcher's policy) recorded the outer policy's published values as the user's, then cleared the record on disposal, so every later child inherited the normalization instead. The record now belongs to the outermost install and is restored, not dropped. `web_fetch` read the active policy twice — once to skip address pinning, again inside the transport — so a disposal landing between the two reads produced an unpinned direct connection to a host nothing validated. One snapshot now decides both. Also: the node:http proxy test asserted a route the engines range does not always have, and the gate could not see undici bound through `await import('undici')`, the form this repository actually uses. --- ...2026-08-27-outbound-proxy-policy.i18n.yaml | 4 +- .../2026-08-27-outbound-proxy-policy.md | 4 +- .../2026-08-27-outbound-proxy-policy.zh.md | 4 +- docs/user/guide/network-proxy.i18n.yaml | 4 +- docs/user/guide/network-proxy.md | 2 +- docs/user/guide/network-proxy.zh.md | 2 +- packages/net/http-proxy/README.i18n.yaml | 4 +- packages/net/http-proxy/README.md | 4 +- packages/net/http-proxy/README.zh.md | 4 +- packages/net/http-proxy/package.json | 2 +- packages/net/http-proxy/src/install.ts | 107 ++++++++++---- packages/net/http-proxy/tests/install.spec.ts | 135 +++++++++++++++++- .../http-proxy/tests/matcher-parity.spec.ts | 19 ++- .../subprocess/tests/egress.spec.ts | 54 ++++++- packages/web/web-fetch-http/src/network.ts | 15 +- packages/web/web-fetch-http/src/provider.ts | 11 +- scripts/verify-no-bare-dispatcher.spec.ts | 37 +++++ scripts/verify-no-bare-dispatcher.ts | 71 +++++++-- 18 files changed, 407 insertions(+), 76 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml index 6bbb407722..77039047ac 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md -2026-08-27-outbound-proxy-policy.md: 0213ab056bb3c4eb417788b739daca0adc5be669 -2026-08-27-outbound-proxy-policy.zh.md: 312b9197acfb47edb51eb4413e15c57b492cdce6 +2026-08-27-outbound-proxy-policy.md: 5c017d8c36321877981383f083b739f7b5622ccb +2026-08-27-outbound-proxy-policy.zh.md: 7efe83a75c4c04695dc422cb87365226e249be1e diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md index 0213ab056b..5c017d8c36 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md @@ -20,7 +20,7 @@ Resolution reads the launcher's snapshot rather than `process.env`, which is wha **A new `packages/net/` group.** The package must depend on `undici` (Node exposes no `node:undici`), so it cannot join the zero-dependency `util/` group; and `boot`, `web`, `subprocess`, and `workflow` all consume it, so joining any one of them would invert three dependencies. It is deliberately not a capability seam: transport policy has one implementation and one answer per process, so there is nothing to swap. -**The installed agent reads back what was resolved, not the raw environment.** `installGlobalProxy` publishes the policy into the proxy environment variables and then constructs `EnvHttpProxyAgent` with no options. Passing the fields explicitly instead would let undici fall back to reading the environment for any field left `undefined` — including a SOCKS or malformed value this package had already rejected, which `new ProxyAgent` would then throw on during boot. Publishing also normalizes what children inherit: the `ALL_PROXY` fallback lands as a concrete `HTTP_PROXY`, and the bypass list arrives with loopback merged. +**The installed dispatcher routes by the policy, not by an environment it re-parses.** `installGlobalProxy` builds an `Agent` whose per-origin `factory` asks `proxyForUrl` where that origin goes, and returns a `ProxyAgent` or undici's own default client for it. undici's `EnvHttpProxyAgent` was the first choice and is wrong for this policy: when no `HTTPS_PROXY` is present it sets its HTTPS agent to the HTTP one, so a scheme this package keeps direct after refusing a SOCKS or malformed URL would still be tunnelled while the diagnostic said otherwise. Routing through the one predicate removes that class of divergence by construction rather than by test. Publishing the policy into the environment remains, but now serves only the readers that have no policy object: Node's `proxyEnv` option and every spawned child. This keeps `proxyForUrl()` and the dispatcher answering from one set of values. They must agree: if they disagreed about a URL, `web-fetch-http` would pin a connection the dispatcher meant to tunnel. @@ -78,6 +78,6 @@ The suite is hermetic against the developer's own environment: `plugin.spec.ts` `verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `createDispatcher`, accepts an annotated exemption, and passes on the current tree. -The egress suite carries the negative case for telemetry — restoring the SDK's own default agent reaches no proxy — so an upgrade cannot quietly un-proxy it. Its positive case branches on the runtime, because the exporter's agent needs Node 22.21+ or 24.5+. A parity suite pins the two bypass matchers (`proxyForUrl` and the installed `EnvHttpProxyAgent`) against each other over the documented `NO_PROXY` vocabulary. +The egress suite carries the negative case for telemetry — restoring the SDK's own default agent reaches no proxy — so an upgrade cannot quietly un-proxy it. Its positive case branches on the runtime, because the exporter's agent needs Node 22.21+ or 24.5+. A parity suite checks `proxyForUrl` against where a real `fetch` actually went for every form in the documented `NO_PROXY` vocabulary; since the dispatcher routes by that same predicate, what it now catches is a form `bypassesProxy` reads differently from how the vocabulary documents it, and any future dispatcher that reintroduces a second matcher. No recorded-session snapshot changes: nothing here alters a model-visible input or product-user-visible transcript output. diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md index 312b9197ac..7efe83a75c 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md @@ -20,7 +20,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 **新增 `packages/net/` 分组。** 本包必须依赖 `undici`(Node 不暴露 `node:undici`),因此无法加入零依赖的 `util/` 组;而 `boot`、`web`、`subprocess` 与 `workflow` 都消费它,放进其中任何一组都会让另外三条依赖反向。它刻意不是能力接缝:传输策略每个进程只有一种实现、一个答案,没有可替换的对象。 -**已安装的 agent 读回的是解析结果,而非原始环境。** `installGlobalProxy` 把策略发布到代理环境变量中,再以无选项方式构造 `EnvHttpProxyAgent`。若改为显式传字段,undici 会对任何留空的字段回退去读环境——包括本包已经拒绝的 SOCKS 或畸形值,而 `new ProxyAgent` 会因此在启动期抛出。发布同时也规范化了子进程继承到的内容:`ALL_PROXY` 兜底落为具体的 `HTTP_PROXY`,绕过列表也已并入 loopback。 +**已安装的 dispatcher 按策略路由,而不是重新解析一遍环境。** `installGlobalProxy` 构造一个 `Agent`,其按 origin 调用的 `factory` 会询问 `proxyForUrl` 该 origin 的去向,并据此返回 `ProxyAgent` 或 undici 自带的默认客户端。undici 的 `EnvHttpProxyAgent` 曾是首选,但对这套策略是错的:没有 `HTTPS_PROXY` 时它会把 HTTPS agent 设为 HTTP agent,于是本包在拒绝某个 SOCKS 或畸形 URL 后本应保持直连的 scheme 仍会被隧道转发,而诊断却声称直连。让路由走同一个谓词,从构造上而非靠测试消除了这一类分歧。把策略发布到环境中的做法保留下来,但如今只服务那些拿不到策略对象的读者:Node 的 `proxyEnv` 选项,以及每个派生的子进程。 这样 `proxyForUrl()` 与 dispatcher 就从同一组值给出答案。两者必须一致:一旦对某个 URL 产生分歧,`web-fetch-http` 就会把 dispatcher 本打算隧道转发的连接固定到某个地址上。 @@ -78,6 +78,6 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l `verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `createDispatcher`、接受带注释的豁免,并在当前代码树上通过。 -出网测试为遥测保留了负向用例——恢复 SDK 自带的默认 agent 就触及不到代理——因此升级无法悄悄把它变回直连。其正向用例按运行时分支,因为导出器的 agent 需要 Node 22.21+ 或 24.5+。另有一组一致性测试,用文档所述的 `NO_PROXY` 词汇把两套绕过匹配器(`proxyForUrl` 与已安装的 `EnvHttpProxyAgent`)相互固定。 +出网测试为遥测保留了负向用例——恢复 SDK 自带的默认 agent 就触及不到代理——因此升级无法悄悄把它变回直连。其正向用例按运行时分支,因为导出器的 agent 需要 Node 22.21+ 或 24.5+。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。 无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。 diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml index 7703dfeb00..1872a1b899 100644 --- a/docs/user/guide/network-proxy.i18n.yaml +++ b/docs/user/guide/network-proxy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/network-proxy.md -network-proxy.md: 33f84766967ccac501629e930eaaef1b1c24d8b9 -network-proxy.zh.md: 82aa63bb042d873c1fb16090a2a289dac033c4aa +network-proxy.md: 98722f4562bb81b4b3d015770fb04de495e09e95 +network-proxy.zh.md: 07c8455f37f1022e4c4da0001d97b30e8dafc308 diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md index 33f8476696..98722f4562 100644 --- a/docs/user/guide/network-proxy.md +++ b/docs/user/guide/network-proxy.md @@ -45,7 +45,7 @@ You do not need to list `localhost` or `127.0.0.1`. DSH always bypasses loopback ## Limits worth knowing -**SOCKS proxies are not supported.** A `socks5://` value is reported at startup and skipped, and DSH connects directly. Point the variables at your proxy application's HTTP port instead — most expose both, and the HTTP one is usually a neighbouring port number. +**SOCKS proxies are not supported.** A `socks5://` value is reported at startup and skipped, and DSH connects directly for the scheme that named it — setting `HTTPS_PROXY=socks5://…` alongside a usable `HTTP_PROXY` leaves `https:` direct rather than borrowing the HTTP proxy. Point the variables at your proxy application's HTTP port instead — most expose both, and the HTTP one is usually a neighbouring port number. **`ALL_PROXY` alone is enough.** DSH falls back to it for both schemes, even though Node and curl differ on this. Setting `HTTPS_PROXY` explicitly is still clearer. diff --git a/docs/user/guide/network-proxy.zh.md b/docs/user/guide/network-proxy.zh.md index 82aa63bb04..07c8455f37 100644 --- a/docs/user/guide/network-proxy.zh.md +++ b/docs/user/guide/network-proxy.zh.md @@ -45,7 +45,7 @@ export NO_PROXY=internal.example.com,.corp.example.com,registry.local ## 值得知道的限制 -**不支持 SOCKS 代理。** `socks5://` 形式的值会在启动时被报告并跳过,DSH 转为直连。请把变量指向代理软件的 HTTP 端口——多数软件两者都提供,且 HTTP 端口通常就在相邻的端口号上。 +**不支持 SOCKS 代理。** `socks5://` 形式的值会在启动时被报告并跳过,指定它的那个 scheme 转为直连——把 `HTTPS_PROXY=socks5://…` 与一个可用的 `HTTP_PROXY` 一起设置时,`https:` 会保持直连,而不会去借用 HTTP 代理。请把变量指向代理软件的 HTTP 端口——多数软件两者都提供,且 HTTP 端口通常就在相邻的端口号上。 **只设 `ALL_PROXY` 也够用。** DSH 会用它为两种协议兜底,尽管 Node 与 curl 在这一点上并不一致。显式设置 `HTTPS_PROXY` 仍然更清楚。 diff --git a/packages/net/http-proxy/README.i18n.yaml b/packages/net/http-proxy/README.i18n.yaml index 8a95acfe0b..45cfd664fc 100644 --- a/packages/net/http-proxy/README.i18n.yaml +++ b/packages/net/http-proxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/net/http-proxy/README.md -README.md: 3c0cf6ff7deb915d11104ef5a7fb622dfb951385 -README.zh.md: c6d26b05c1d4fcb4603518a0725cad988efae65a +README.md: 8d28134ba076b0184987995b44924327c26f8096 +README.zh.md: 0711bb39d5077996c29992fe6a89913eaf6ec31e diff --git a/packages/net/http-proxy/README.md b/packages/net/http-proxy/README.md index 3c0cf6ff7d..8d28134ba0 100644 --- a/packages/net/http-proxy/README.md +++ b/packages/net/http-proxy/README.md @@ -59,9 +59,9 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri ### Design philosophy -**One resolution, two readers.** `proxyForUrl()` and the installed dispatcher must never disagree about a URL, or `dsh-web-fetch-http` would pin a connection the dispatcher meant to tunnel. Installation therefore publishes the resolved policy into the proxy environment variables and constructs `EnvHttpProxyAgent` with no options, so the agent reads back exactly what was resolved rather than re-parsing the raw environment under slightly different rules. +**One resolution, one matcher.** `proxyForUrl()` and the installed dispatcher must never disagree about a URL, or `dsh-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. -**A child inherits what the user exported, not what this process resolved.** The published values exist for undici; `childProxyEnv()` restores each name to its original before a child is spawned. Normalizing them into a child would hand `curl` an `HTTPS_PROXY` invented from the HTTP one, or replace a SOCKS proxy the user set for `curl` with an HTTP proxy they never named for that scheme. +**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` reads neither `ALL_PROXY` nor a proxy that came from `cordis.yml`. 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. ### Source map diff --git a/packages/net/http-proxy/README.zh.md b/packages/net/http-proxy/README.zh.md index c6d26b05c1..0711bb39d5 100644 --- a/packages/net/http-proxy/README.zh.md +++ b/packages/net/http-proxy/README.zh.md @@ -59,9 +59,9 @@ loopback 始终被绕过。否则 Harness 自己的 Web UI、Connection 传输 ### 设计理念 -**一次解析,两个读者。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此安装时会把解析出的策略发布到代理环境变量中,并以无选项方式构造 `EnvHttpProxyAgent`,让该 agent 读回的正是解析结果,而不是按略有差异的规则重新解析原始环境。 +**一次解析,一个匹配器。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此该 dispatcher 是一个 `Agent`,其按 origin 调用的 `factory` 自身调用 `proxyForUrl()`,不存在可能与第一个解析器产生漂移的第二个解析器。undici 的 `EnvHttpProxyAgent` 在此无法胜任:没有 `HTTPS_PROXY` 时它让 `https:` 复用 HTTP 代理,于是本包在拒绝用户为该 scheme 指定的 URL 后本应保持直连的 scheme 仍会被隧道转发。 -**子进程继承的是用户导出的值,而非本进程解析出的值。** 写回环境只服务 undici;派生子进程前,`childProxyEnv()` 会把每个变量名还原为原值。把规范化结果塞给子进程,会让 `curl` 拿到一个由 HTTP 代理凭空推出的 `HTTPS_PROXY`,或让用户为 `curl` 设置的 SOCKS 代理被替换成他们从未为该协议指定过的 HTTP 代理。 +**子进程继承用户自己的值,以及用户未设置部分的解析结果。** 用户以任一大小写指定过的 scheme,会以他们书写的形式原样传给子进程,因此用户为 `curl` 设置的 SOCKS 代理绝不会被替换成为其他 scheme 指定的 HTTP 代理。两种大小写都未指定的 scheme 则携带解析值,否则子进程的路由会与父进程分歧:Node 的 `NODE_USE_ENV_PROXY` 既不读 `ALL_PROXY`,也不读来自 `cordis.yml` 的代理。绕过列表始终采用解析结果——它只会追加 loopback 条目,用户写下的内容不会丢失。让父子进程只有一个路由答案的代价是:`curl` 也会看到本包由 HTTP 代理推导出的 `https:` 代理。 ### 源码地图 diff --git a/packages/net/http-proxy/package.json b/packages/net/http-proxy/package.json index 8f675ff505..3b3142c677 100644 --- a/packages/net/http-proxy/package.json +++ b/packages/net/http-proxy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-http-proxy", "description": "Process-wide outbound HTTP proxy policy for DeepSeek Harness: resolve it from the launch environment and install it as undici's global dispatcher", - "version": "0.1.1-rc.2", + "version": "0.1.2-alpha.1", "publishConfig": { "access": "public" }, diff --git a/packages/net/http-proxy/src/install.ts b/packages/net/http-proxy/src/install.ts index 9192c4c7d9..ce95d6ebb4 100644 --- a/packages/net/http-proxy/src/install.ts +++ b/packages/net/http-proxy/src/install.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-http-proxy/install */ -import type { Agent, Dispatcher } from 'undici' +import type { Agent, Dispatcher, Pool } from 'undici' import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from './policy.ts' @@ -16,10 +16,14 @@ import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from ' let active: ProxyPolicy | undefined /** - * The proxy environment as it stood before the active policy was published, or `undefined` when none - * is installed. A spawned child receives these, not the published ones: normalizing what this process - * resolved into a child's environment would replace a value the user set for another tool — a SOCKS - * proxy this package refuses but `curl` uses, or an `HTTPS_PROXY` the user never wrote at all. + * The proxy environment as the user exported it, or `undefined` when no policy is installed. + * + * Owned by the OUTERMOST install: a nested one — the plugin mounted over the launcher's policy — + * would otherwise record the outer policy's published values as if the user had written them, and + * hand every child a normalization the user never asked for. + * + * {@link childProxyEnv} keeps a value the user set rather than the one this process resolved from + * it, so a SOCKS proxy `curl` can use is not replaced by an HTTP proxy named for another scheme. */ let inheritedProxyEnv: Readonly> | undefined @@ -34,9 +38,10 @@ export function currentProxyPolicy(): ProxyPolicy | undefined { } /** - * Publish a policy through the proxy environment variables so both undici and every spawned child - * observe the one resolved answer — including the `ALL_PROXY` fallback and the merged loopback - * bypass, neither of which they would derive on their own. + * Publish a policy through the proxy environment variables, which is how the consumers that read an + * environment rather than a policy object — `node:http`'s `proxyEnv` and every spawned child — see + * the one resolved answer, including the `ALL_PROXY` fallback and the merged loopback bypass that + * neither derives on its own. The global dispatcher does not read these; it routes by the policy. * * @param policy - the policy to publish. * @returns a function restoring every name this call changed. @@ -49,7 +54,8 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void { for (const names of Object.values(POLICY_ENV_NAMES)) { for (const name of names) previous.set(name, process.env[name]) } - inheritedProxyEnv = Object.fromEntries(previous) + const previousInherited = inheritedProxyEnv + inheritedProxyEnv = previousInherited ?? Object.fromEntries(previous) for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) { const value = policy[field as keyof typeof POLICY_ENV_NAMES] for (const name of names) { @@ -62,16 +68,44 @@ function applyPolicyEnv(policy: ProxyPolicy): () => void { if (value === undefined) Reflect.deleteProperty(process.env, name) else process.env[name] = value } - inheritedProxyEnv = undefined + inheritedProxyEnv = previousInherited } } +/** + * Build the global dispatcher for one policy. + * + * Routing runs through {@link proxyForUrl} per origin, so `fetch` and every caller that asks where a + * URL goes read the same answer from the same matcher. undici's `EnvHttpProxyAgent` cannot express + * this policy: with no `HTTPS_PROXY` present it reuses the HTTP proxy for `https:`, which would + * tunnel a scheme this package deliberately keeps direct after refusing the SOCKS or malformed URL + * the user named for it — the route and the diagnostic would then disagree. + * + * @param policy - the policy to route by; it must proxy at least one scheme. + * @returns the dispatcher to install, owning every per-origin agent its factory created. + */ +async function createPolicyDispatcher(policy: ProxyPolicy): Promise { + const { Agent, Pool, ProxyAgent } = await import('undici') + return new Agent({ + factory(origin, options) { + // undici declares this parameter as `Object`, discarding the pool options it actually passes. + const passed = options as Pool.Options + const proxy = proxyForUrl(policy, new URL(origin.toString())) + if (proxy !== undefined) return new ProxyAgent({ ...passed, uri: proxy }) + // What undici's own default factory builds for these options, which `factory` replaces + // wholesale. It reaches for a bare `Client` only at `connections: 1`, an option this + // dispatcher never carries: it is constructed with undici's defaults. + return new Pool(origin, passed) + }, + }) +} + /** * Route this process's outbound HTTP through `policy`. * * Installing replaces undici's global dispatcher, which is what Node's built-in `fetch` resolves, so * every caller that issues a plain `fetch()` is covered without knowing this package exists. A policy - * that proxies nothing installs no dispatcher and leaves the environment untouched. + * that proxies nothing installs a direct dispatcher and leaves the environment untouched. * * Worker threads do not inherit the global dispatcher; each one calls this with the policy its host * passed through `workerData`. @@ -105,11 +139,9 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro } } const restoreEnv = applyPolicyEnv(policy) - const { EnvHttpProxyAgent, getGlobalDispatcher, setGlobalDispatcher } = await import('undici') + const { getGlobalDispatcher, setGlobalDispatcher } = await import('undici') const previousDispatcher = getGlobalDispatcher() - // Constructed with no options on purpose: it reads the names applyPolicyEnv just wrote, so the - // agent and `proxyForUrl` answer from the same values instead of each parsing the raw environment. - const agent = new EnvHttpProxyAgent() + const agent = await createPolicyDispatcher(policy) setGlobalDispatcher(agent) active = policy return async () => { @@ -132,11 +164,19 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro * @param options - agent options; applied to whichever agent the policy selects. On the proxied path * `connect` governs the connection to the PROXY, not to the origin, so a lookup meant to pin an * origin address belongs only on a URL the policy bypasses. + * @param policy - the policy to route by, defaulting to the active one. A caller that already + * branched on {@link proxyForUrl} MUST pass the same policy object it branched on: reading the + * active policy again would let a mount or disposal between the two reads return a direct agent + * for a URL the caller cleared as proxied, dropping the address checks that branch skipped. * @returns a dispatcher the caller owns and must close once the response body is consumed. */ -export async function createDispatcher(url: URL, options: Agent.Options = {}): Promise { +export async function createDispatcher( + url: URL, + options: Agent.Options = {}, + policy: ProxyPolicy = active ?? DIRECT_POLICY, +): Promise { const undici = await import('undici') - const proxy = proxyForUrl(active ?? DIRECT_POLICY, url) + const proxy = proxyForUrl(policy, url) if (proxy === undefined) return new undici.Agent(options) return new undici.ProxyAgent({ ...options, uri: proxy }) } @@ -182,12 +222,19 @@ export function proxyUrlFor(url: URL): string | undefined { /** * The proxy environment a spawned child needs. * - * A child inherits the parent environment, which this process rewrote to its own resolved policy so - * undici reads back exactly what was resolved. That normalization must not reach the child: it would - * hand `curl` an `HTTPS_PROXY` this package invented from the HTTP one, or replace a SOCKS proxy the - * user set for `curl` with an HTTP proxy they never named for that scheme. The result therefore - * restores each name to the value the user exported — `undefined` for a name they never set — and - * adds only the flag that makes a child Node honor them. + * A child inherits the parent environment, which this process rewrote to its own resolved policy. + * Handing that normalization straight through would replace values the user set for other tools, so + * each proxy name the user exported is restored to what they wrote: a SOCKS proxy `curl` uses is + * not swapped for the HTTP one this package fell back to for that scheme. + * + * A scheme the user named in neither casing carries the resolved value instead of being removed. + * Without that the child's routing silently diverges from its parent's: `NODE_USE_ENV_PROXY` reads + * neither `ALL_PROXY` nor a proxy that came from `cordis.yml`, so the child would connect directly + * while the parent proxies. + * + * The bypass list is always the resolved one. It only ever adds the loopback entries to what + * the user wrote, so nothing is lost, and the child stops sending its own localhost traffic to a + * proxy that cannot route it. * * The flag reaches only Node 22.21+ and 24+; an older runtime keeps that child direct. Such a child * also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in @@ -201,6 +248,16 @@ export function proxyUrlFor(url: URL): string | undefined { * object when no proxy is active. */ export function childProxyEnv(): Readonly> { - if (active === undefined || active.source === 'none' || inheritedProxyEnv === undefined) return {} - return { ...inheritedProxyEnv, NODE_USE_ENV_PROXY: '1' } + const policy = active + const inherited = inheritedProxyEnv + if (policy === undefined || policy.source === 'none' || inherited === undefined) return {} + const overlay: Record = { NODE_USE_ENV_PROXY: '1' } + for (const [field, names] of Object.entries(POLICY_ENV_NAMES)) { + const resolved = policy[field as keyof typeof POLICY_ENV_NAMES] + // Naming a scheme in either casing claims that scheme: the child then gets exactly what the + // user wrote, in the casing they wrote it, rather than a value derived for this process. + const named = field !== 'noProxy' && names.some(name => inherited[name] !== undefined) + for (const name of names) overlay[name] = named ? inherited[name] : resolved + } + return overlay } diff --git a/packages/net/http-proxy/tests/install.spec.ts b/packages/net/http-proxy/tests/install.spec.ts index 28ffdf2af0..3699f4e91e 100644 --- a/packages/net/http-proxy/tests/install.spec.ts +++ b/packages/net/http-proxy/tests/install.spec.ts @@ -37,6 +37,10 @@ beforeAll(async () => { response.writeHead(200, { 'content-type': 'text/plain' }) response.end('VIA-PROXY') }) + proxy.on('connect', (request, socket) => { + proxied.push(`CONNECT ${request.url ?? ''}`) + socket.end() + }) origin = createServer((_request, response) => { response.end('DIRECT') }) const [proxyAddress, originAddress] = await Promise.all([listen(proxy), listen(origin)]) proxyUrl = `http://127.0.0.1:${String(proxyAddress.port)}` @@ -51,6 +55,9 @@ afterEach(() => { proxied = [] }) +/** A second proxy URL, never dialed: it only has to differ from {@link proxyUrl} in an assertion. */ +const nestedUrl = 'http://127.0.0.1:9' + /** A policy proxying everything, since the resolved default always bypasses the loopback these tests use. */ function proxyAll(noProxy = ''): ProxyPolicy { return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy, source: 'env' } @@ -129,6 +136,21 @@ describe('installGlobalProxy', () => { } expect(currentProxyPolicy()).toBeUndefined() }) + it('keeps a scheme direct when the policy refused the proxy the user named for it', async () => { + // What `HTTPS_PROXY=socks5://…` plus `HTTP_PROXY=http://p` resolves to: http proxied, https + // direct. undici's own EnvHttpProxyAgent cannot express this — with no HTTPS proxy present it + // reuses the HTTP one, tunnelling the scheme the diagnostic told the user stayed direct. + const dispose = await installGlobalProxy({ httpProxy: proxyUrl, noProxy: '', source: 'env' }) + try { + await expect(fetch('https://refused-scheme.invalid/')).rejects.toThrow() + expect(proxied).toEqual([]) + // The same policy still tunnels http, so the empty expectation above is not vacuous. + await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY') + expect(proxied).toEqual([`GET ${originUrl}`]) + } finally { + await dispose() + } + }) }) describe('createDispatcher', () => { @@ -168,6 +190,23 @@ describe('createDispatcher', () => { await dispatcher.close() } }) + + it('routes by the policy it was handed, not one replaced after the caller branched', async () => { + const dispose = await installGlobalProxy(proxyAll()) + const branched = currentProxyPolicy() + expect(branched).toBeDefined() + // The caller has already decided this hop is proxied and skipped its address checks. Unmounting + // the plugin here is what a hot reload does mid-request; reading the active policy again would + // hand back a direct agent and connect to an origin nothing validated. + await dispose() + const dispatcher = await createDispatcher(new URL(originUrl), {}, branched) + try { + const undici = await import('undici') + await expect((await undici.fetch(originUrl, { dispatcher })).text()).resolves.toBe('VIA-PROXY') + } finally { + await dispatcher.close() + } + }) }) describe('childProxyEnv', () => { @@ -199,7 +238,10 @@ describe('childProxyEnv', () => { expect(child.https_proxy).toBe('socks5://127.0.0.1:1080') expect(child.HTTPS_PROXY).toBeUndefined() expect(child.HTTP_PROXY).toBe(proxyUrl) - expect(child.no_proxy).toBeUndefined() + // The bypass list is the resolved one even though the user set none: it only adds entries, + // and without it the child sends its own loopback traffic to a proxy that cannot route it. + expect(child.no_proxy).toBe('example.com') + expect(child.NO_PROXY).toBe('example.com') expect(child.NODE_USE_ENV_PROXY).toBe('1') } finally { await dispose() @@ -209,6 +251,83 @@ describe('childProxyEnv', () => { } } }) + it('fills a scheme the user named in neither casing, so a child Node is not left direct', async () => { + const saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]])) + for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name) + // The user exported only ALL_PROXY. `NODE_USE_ENV_PROXY` never reads that name, so a child + // Node would connect directly while this process proxies — the seam this fill closes. + process.env.ALL_PROXY = proxyUrl + const dispose = await installGlobalProxy(proxyAll('example.com')) + try { + const child = childProxyEnv() + expect(child.HTTP_PROXY).toBe(proxyUrl) + expect(child.http_proxy).toBe(proxyUrl) + expect(child.HTTPS_PROXY).toBe(proxyUrl) + expect(child.https_proxy).toBe(proxyUrl) + } finally { + await dispose() + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } + } + }) + + it('propagates a proxy that only a composition declared', async () => { + const saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]])) + for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name) + const dispose = await installGlobalProxy({ ...proxyAll('example.com'), source: 'config' }) + try { + // Nothing was exported, so every name carries the configured policy rather than being removed. + expect(childProxyEnv()).toEqual({ + NODE_USE_ENV_PROXY: '1', + http_proxy: proxyUrl, + HTTP_PROXY: proxyUrl, + https_proxy: proxyUrl, + HTTPS_PROXY: proxyUrl, + no_proxy: 'example.com', + NO_PROXY: 'example.com', + }) + } finally { + await dispose() + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } + } + }) + + it('keeps the outermost install\'s record of what the user exported across a nested one', async () => { + const saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]])) + for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name) + // The user exported one name, in one casing. + process.env.HTTP_PROXY = proxyUrl + const nested: ProxyPolicy = { httpProxy: nestedUrl, httpsProxy: nestedUrl, noProxy: '', source: 'env' } + // The launcher installs first; mounting the plugin installs a second policy over it. + const disposeOuter = await installGlobalProxy(proxyAll('example.com')) + try { + const disposeInner = await installGlobalProxy(nested) + try { + const child = childProxyEnv() + // Recording the outer install's published environment as the user's would show the + // lowercase name it wrote and the outer proxy for a scheme the user never named. + expect(child.http_proxy).toBeUndefined() + expect(child.https_proxy).toBe(nestedUrl) + } finally { + await disposeInner() + } + // Unmounting the inner install must leave the outer one still able to describe that + // environment; clearing the record instead sends every later child the normalized values. + expect(childProxyEnv().HTTP_PROXY).toBe(proxyUrl) + expect(childProxyEnv().http_proxy).toBeUndefined() + } finally { + await disposeOuter() + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } + } + }) }) describe('installGlobalProxy over an existing installation', () => { @@ -250,6 +369,16 @@ describe('applyPolicyEnv restoration', () => { }) describe('createNodeHttpAgent', () => { + /** + * Whether this runtime's `http.Agent` honors `proxyEnv`, the option this agent routes through. + * Added in Node 24.5 and backported to 22.21; the engines range admits 22.19, 22.20, and + * 24.0–24.4, where the option is ignored and the request stays direct. + */ + function supportsAgentProxyEnv(): boolean { + const [major = 0, minor = 0] = process.versions.node.split('.').map(Number) + return (major === 24 && minor >= 5) || major > 24 || (major === 22 && minor >= 21) + } + /** Drive a real `node:http` request, which the global dispatcher never reaches. */ function get(target: string, agent: http.Agent): Promise { return new Promise((resolve) => { @@ -265,7 +394,9 @@ describe('createNodeHttpAgent', () => { const dispose = await installGlobalProxy(proxyAll()) const agent = await createNodeHttpAgent('http:') try { - await expect(get(originUrl, agent)).resolves.toBe('VIA-PROXY') + // An older runtime ignores the unknown `proxyEnv` option and connects directly — the seam + // this agent's documentation names, asserted rather than left to fail the suite there. + await expect(get(originUrl, agent)).resolves.toBe(supportsAgentProxyEnv() ? 'VIA-PROXY' : 'DIRECT') } finally { agent.destroy() await dispose() diff --git a/packages/net/http-proxy/tests/matcher-parity.spec.ts b/packages/net/http-proxy/tests/matcher-parity.spec.ts index 9156a7f38c..e3c7c89ac2 100644 --- a/packages/net/http-proxy/tests/matcher-parity.spec.ts +++ b/packages/net/http-proxy/tests/matcher-parity.spec.ts @@ -4,10 +4,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { installGlobalProxy, proxyForUrl, type ProxyPolicy } from '../src/index.ts' /** - * `proxyForUrl` and the installed `EnvHttpProxyAgent` are two matchers over one bypass list. They are - * fed the same values, but their parsers are independent: a form they judge differently would route a - * plain `fetch` one way and `web_fetch` the other. These cases pin the forms in the documented - * vocabulary against the agent's real behavior. + * `proxyForUrl` answers where a URL goes; these cases check that answer against where a real `fetch` + * actually went, for every form in the documented bypass vocabulary. The installed dispatcher routes + * by this same predicate, so the two cannot drift apart by parsing the list twice — what a case can + * still catch is `bypassesProxy` reading a form differently from how the vocabulary documents it, + * and any future dispatcher that reintroduces a second matcher. + * + * The remaining second matcher is Node's, on the `node:http` path: `createNodeHttpAgent` hands it + * the published `NO_PROXY` and Node applies its own rules, which differ in separators and IPv4-range + * support. That seam is documented rather than asserted here, because the difference is real. */ const CASES: readonly { readonly noProxy: string; readonly path: string; readonly bypassed: boolean }[] = [ { noProxy: '', path: '/plain', bypassed: false }, @@ -51,8 +56,10 @@ describe('bypass matcher parity', () => { const url = new URL(`http://probe.invalid${path}`) const dispose = await installGlobalProxy(policy(noProxy)) try { - // A bypassed target has no route here, so the fetch fails; a proxied one reaches the recorder. - await fetch(url).then(response => response.text()).catch(() => undefined) + // A bypassed target has no route here, so the fetch fails; a proxied one reaches the recorder + // in milliseconds. The deadline bounds the failing path, whose DNS miss is otherwise as slow + // as the machine's resolver decides — and only that path, so it cannot mask a proxied hop. + await fetch(url, { signal: AbortSignal.timeout(1500) }).then(response => response.text()).catch(() => undefined) const agentProxied = seen.length > 0 expect({ ours: proxyForUrl(policy(noProxy), url) !== undefined, agent: agentProxied }) .toEqual({ ours: !bypassed, agent: !bypassed }) diff --git a/packages/subprocess/subprocess/tests/egress.spec.ts b/packages/subprocess/subprocess/tests/egress.spec.ts index 6e4eb06c14..b20b6f961d 100644 --- a/packages/subprocess/subprocess/tests/egress.spec.ts +++ b/packages/subprocess/subprocess/tests/egress.spec.ts @@ -97,17 +97,63 @@ describe('child process egress', () => { else expect(seen).toEqual([]) }) - it('does not invent a proxy name the user never exported', async () => { + it('a child Node reaches a proxy the user gave only as ALL_PROXY', async () => { + process.env.ALL_PROXY = proxyUrl + const { policy } = resolveProxyPolicy( + createLaunchEnvironmentSnapshot([{ source: 'process', values: { ALL_PROXY: proxyUrl } }]), + ) + const dispose = await installGlobalProxy(policy) + let childEnv: Record = {} + try { + childEnv = scrubbedParentEnv() + await childFetch('http://all-proxy-probe.invalid/x', childEnv) + } finally { + await dispose() + } + // `NODE_USE_ENV_PROXY` reads neither casing of `ALL_PROXY`, so a child handed only the user's + // own names connects directly while this process proxies. The resolved value fills that gap. + expect(childEnv.ALL_PROXY).toBe(proxyUrl) + expect(childEnv.HTTP_PROXY).toBe(proxyUrl) + if (supportsEnvProxy()) expect(seen.join('|')).toContain('all-proxy-probe.invalid') + else expect(seen).toEqual([]) + }) + + it('keeps a proxy the user set for another tool, and fills only a scheme they never named', async () => { + // A SOCKS proxy this package refuses but `curl` uses, alongside an HTTP proxy it accepts. + process.env.HTTP_PROXY = proxyUrl + process.env.https_proxy = 'socks5://127.0.0.1:1080' + const { policy } = resolveProxyPolicy( + createLaunchEnvironmentSnapshot([{ + source: 'process', + values: { HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' }, + }]), + ) + const dispose = await installGlobalProxy(policy) + try { + const child = scrubbedParentEnv() + // The user named `https:`, so their value survives in the casing they wrote it, even though + // this process refused it and routes that scheme directly. + expect(child.https_proxy).toBe('socks5://127.0.0.1:1080') + expect(child.HTTPS_PROXY).toBeUndefined() + // The bypass list is always the resolved one; it only ever adds the loopback entries. + expect(child.NO_PROXY).toBe(policy.noProxy) + } finally { + await dispose() + } + }) + + it('gives a child the same routing as its parent for a scheme the user never named', async () => { process.env.HTTP_PROXY = proxyUrl const { policy } = resolveProxyPolicy( createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]), ) const dispose = await installGlobalProxy(policy) try { - // This process resolved an HTTPS proxy by falling back to the HTTP one; a child must not see - // a name the user never set, because `curl` performs no such fallback of its own. + // This process routes `https:` through the HTTP proxy, matching undici. A child that did not + // see the name would diverge from its parent; `curl`, which performs no such fallback of its + // own, gains it here — the deliberate cost of one routing answer for parent and child alike. expect(process.env.HTTPS_PROXY).toBe(proxyUrl) - expect(scrubbedParentEnv().HTTPS_PROXY).toBeUndefined() + expect(scrubbedParentEnv().HTTPS_PROXY).toBe(proxyUrl) } finally { await dispose() } diff --git a/packages/web/web-fetch-http/src/network.ts b/packages/web/web-fetch-http/src/network.ts index dd7d66ddca..3c83063acf 100644 --- a/packages/web/web-fetch-http/src/network.ts +++ b/packages/web/web-fetch-http/src/network.ts @@ -10,7 +10,7 @@ import { lookup as systemLookup } from 'node:dns/promises' import type { LookupAddress, LookupOptions } from 'node:dns' import { isIP } from 'node:net' import type { Agent, Response } from 'undici' -import { createDispatcher } from '@deepseek-ai/dsh-http-proxy' +import { createDispatcher, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' import ipaddr from 'ipaddr.js' import { WebError } from '@deepseek-ai/dsh-web' @@ -166,6 +166,7 @@ function embeddedIpv4Address(bytes: readonly number[], prefixLength: Nat64Prefix * @param addresses - public addresses returned by {@link resolvePublicAddresses}. * @param headers - request headers. * @param signal - request and body-read cancellation signal. + * @param policy - the proxy policy the caller already branched on; omitted, the active one is read. * @returns a response plus the dispatcher disposer its consumer must call. */ export async function requestPinned( @@ -173,11 +174,12 @@ export async function requestPinned( addresses: readonly PublicAddress[], headers: Record, signal: AbortSignal, + policy?: ProxyPolicy, ): Promise { return await requestWith(url, headers, signal, { autoSelectFamily: true, connect: { lookup: createPinnedLookup(addresses) }, - }) + }, policy) } /** @@ -191,14 +193,17 @@ export async function requestPinned( * @param url - validated HTTP(S) URL the active policy routes through a proxy. * @param headers - request headers. * @param signal - request and body-read cancellation signal. + * @param policy - the proxy policy the caller branched on; passing it keeps this hop on the route + * that decision assumed even if the policy is replaced while the request is in flight. * @returns a response plus the dispatcher disposer its consumer must call. */ export async function requestProxied( url: URL, headers: Record, signal: AbortSignal, + policy?: ProxyPolicy, ): Promise { - return await requestWith(url, headers, signal, {}) + return await requestWith(url, headers, signal, {}, policy) } /** @@ -211,6 +216,7 @@ export async function requestProxied( * @param headers - request headers. * @param signal - request and body-read cancellation signal. * @param options - agent options applied to whichever agent the policy selects. + * @param policy - the policy to route by, defaulting to the active one. * @returns a response plus the dispatcher disposer its consumer must call. */ async function requestWith( @@ -218,12 +224,13 @@ async function requestWith( headers: Record, signal: AbortSignal, options: Agent.Options, + policy?: ProxyPolicy, ): Promise { // Keep the Node-only transport out of browser-worker startup. The preview // can load the provider and fail loud at its DNS stub without evaluating // Undici; a real request on Node resolves this maintained dependency here. const { fetch } = await import('undici') - const dispatcher = await createDispatcher(url, options) + const dispatcher = await createDispatcher(url, options, policy) try { // proxy-exempt: the dispatcher is createDispatcher's, which already applied the active policy. const response = await fetch(url, { method: 'GET', redirect: 'manual', headers, signal, dispatcher }) diff --git a/packages/web/web-fetch-http/src/provider.ts b/packages/web/web-fetch-http/src/provider.ts index 223489321b..75c89d6e40 100644 --- a/packages/web/web-fetch-http/src/provider.ts +++ b/packages/web/web-fetch-http/src/provider.ts @@ -124,11 +124,16 @@ export class HttpFetchProvider implements WebFetchProvider { // DNS, so there is no local address to validate, and pinning one would connect directly and // bypass the proxy. A hop the policy bypasses — every loopback and every `NO_PROXY` entry — // still takes the resolved-and-pinned path unchanged. - if (proxyForUrl(currentProxyPolicy() ?? DIRECT_POLICY, url) !== undefined) { - return await publicHttpNetwork.requestProxied(url, headers, signal) + // + // One snapshot decides both the branch and the dispatcher. Reading the active policy again + // inside the transport would let a mount or disposal land between the two reads and return a + // direct, unpinned agent for a URL this branch cleared as proxied. + const policy = currentProxyPolicy() ?? DIRECT_POLICY + if (proxyForUrl(policy, url) !== undefined) { + return await publicHttpNetwork.requestProxied(url, headers, signal, policy) } const addresses = await this.resolveAddresses(url.hostname, signal) - return await publicHttpNetwork.request(url, addresses, headers, signal) + return await publicHttpNetwork.request(url, addresses, headers, signal, policy) } catch (error: unknown) { if (error instanceof WebError) throw error throw translateAbortOrNetwork(error, signal) diff --git a/scripts/verify-no-bare-dispatcher.spec.ts b/scripts/verify-no-bare-dispatcher.spec.ts index 3bc67ad424..1a838628fd 100644 --- a/scripts/verify-no-bare-dispatcher.spec.ts +++ b/scripts/verify-no-bare-dispatcher.spec.ts @@ -59,6 +59,43 @@ describe('bare dispatcher check', () => { `)).toEqual(['constructs an undici agent']) }) + it('rejects a destructured dynamic import, the form this repository loads undici with', () => { + expect(reasons(` + const { Agent } = await import('undici') + const agent = new Agent({}) + `)).toEqual(['constructs an undici agent']) + }) + + it('rejects a renamed binding from a dynamic import', () => { + expect(reasons(` + const { ProxyAgent: Tunnel } = await import('undici') + const agent = new Tunnel({ uri }) + `)).toEqual(['constructs an undici agent']) + }) + + it('rejects a namespace bound by a dynamic import', () => { + expect(reasons(` + const undici = await import('undici') + const agent = new undici.Agent({}) + `)).toEqual(['constructs an undici agent']) + }) + + it('finds a dynamic import nested inside a function, not only at the top level', () => { + expect(reasons(` + export async function requestWith(url) { + const { Agent } = await import('undici') + return new Agent({}) + } + `)).toEqual(['constructs an undici agent']) + }) + + it('accepts a dynamic import of an unrelated module that exports Agent', () => { + expect(reasons(` + const { Agent } = await import('./our-own-agent.ts') + const agent = new Agent({}) + `)).toEqual([]) + }) + it('accepts an unrelated class that happens to be named Agent', () => { expect(reasons(` import { Agent } from './our-own-agent.ts' diff --git a/scripts/verify-no-bare-dispatcher.ts b/scripts/verify-no-bare-dispatcher.ts index 16ca7f0477..57afbb302b 100644 --- a/scripts/verify-no-bare-dispatcher.ts +++ b/scripts/verify-no-bare-dispatcher.ts @@ -11,7 +11,9 @@ * * Discovery is syntax-aware, as `scripts/AGENTS.md` requires: a line-wise regex misses the * `{ dispatcher }` shorthand and a `new Alias(...)` whose import renamed `Agent`, and both bypass the - * proxy exactly as the spelled-out forms do. + * proxy exactly as the spelled-out forms do. Bindings from a dynamic `await import('undici')` count + * the same as static ones — that is how this repository loads undici wherever the transport must + * stay out of a browser-worker's startup graph. */ import { globSync, readFileSync } from 'node:fs' @@ -52,8 +54,42 @@ export interface DispatcherViolation { } /** - * Local names bound to an undici agent class, including `import { Agent as X }` renames and a - * namespace import's own name so `undici.Agent` is recognised too. + * Whether an expression is `import('undici')`, with or without `await`. The dynamic form is how + * this repository loads undici everywhere the transport must stay out of a browser-worker's startup + * graph, so a gate blind to it would miss the repository's own idiom. + * + * @param expression - a variable declaration's initializer, when it has one. + * @returns true when evaluating it yields the undici module. + */ +function isUndiciImport(expression: ts.Expression | undefined): boolean { + if (expression === undefined) return false + const call = ts.isAwaitExpression(expression) ? expression.expression : expression + if (!ts.isCallExpression(call) || call.expression.kind !== ts.SyntaxKind.ImportKeyword) return false + const [specifier] = call.arguments + return specifier !== undefined && ts.isStringLiteral(specifier) && specifier.text === AGENT_MODULE +} + +/** + * Record the names one destructured dynamic import binds to an agent class. + * + * @param pattern - the binding pattern of `const { Agent, ProxyAgent: P } = await import('undici')`. + * @param agents - collector the local names are added to. + */ +function collectDestructuredAgents(pattern: ts.ObjectBindingPattern, agents: Set): void { + for (const element of pattern.elements) { + if (!ts.isIdentifier(element.name)) continue + const property = element.propertyName + const imported = property === undefined + ? element.name.text + : ts.isIdentifier(property) || ts.isStringLiteral(property) ? property.text : undefined + if (imported !== undefined && AGENT_EXPORTS.has(imported)) agents.add(element.name.text) + } +} + +/** + * Local names bound to an undici agent class, including `import { Agent as X }` renames, the + * destructured and namespace forms of a dynamic `import('undici')`, and a namespace import's own + * name so `undici.Agent` is recognised too. * * @param source - the parsed file. * @returns agent identifiers and namespace identifiers bound in this file. @@ -61,20 +97,25 @@ export interface DispatcherViolation { function agentBindings(source: ts.SourceFile): { agents: Set; namespaces: Set } { const agents = new Set() const namespaces = new Set() - for (const statement of source.statements) { - if (!ts.isImportDeclaration(statement)) continue - if (!ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== AGENT_MODULE) continue - const bindings = statement.importClause?.namedBindings - if (bindings === undefined) continue - if (ts.isNamespaceImport(bindings)) { - namespaces.add(bindings.name.text) - continue - } - for (const element of bindings.elements) { - const imported = (element.propertyName ?? element.name).text - if (AGENT_EXPORTS.has(imported)) agents.add(element.name.text) + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node)) { + if (ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === AGENT_MODULE) { + const bindings = node.importClause?.namedBindings + if (bindings !== undefined && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text) + else if (bindings !== undefined) { + for (const element of bindings.elements) { + const imported = (element.propertyName ?? element.name).text + if (AGENT_EXPORTS.has(imported)) agents.add(element.name.text) + } + } + } + } else if (ts.isVariableDeclaration(node) && isUndiciImport(node.initializer)) { + if (ts.isIdentifier(node.name)) namespaces.add(node.name.text) + else if (ts.isObjectBindingPattern(node.name)) collectDestructuredAgents(node.name, agents) } + ts.forEachChild(node, visit) } + ts.forEachChild(source, visit) return { agents, namespaces } } From 4623c68e700c1c2175c5c561b58ca430941d00ec Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 28 Aug 2026 15:09:54 +0800 Subject: [PATCH 009/131] perf(scripts): parse only the files the dispatcher gate can find a violation in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agentBindings` now walks the whole tree to reach a dynamic `import('undici')`, which pushed the repository-wide scan past the 5s default under the coverage lane's instrumentation. Both violations name one of two words in source: an agent construction needs a binding from the undici module, and the option is a property called `dispatcher`. Skipping a file that mentions neither leaves 21 of 1597 files to parse, so the scan runs in ~60ms instead of ~500ms — well clear of the timeout even instrumented. --- scripts/verify-no-bare-dispatcher.spec.ts | 11 +++++++++++ scripts/verify-no-bare-dispatcher.ts | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/scripts/verify-no-bare-dispatcher.spec.ts b/scripts/verify-no-bare-dispatcher.spec.ts index 1a838628fd..99034290f8 100644 --- a/scripts/verify-no-bare-dispatcher.spec.ts +++ b/scripts/verify-no-bare-dispatcher.spec.ts @@ -126,6 +126,17 @@ describe('bare dispatcher check', () => { expect(reasons("import { Agent } from 'undici'\nconst agent = new Agent({})", DISPATCHER_OWNER.replaceAll('/', '\\') + 'src\\install.ts')).toEqual([]) }) + it('parses only a file naming undici or the dispatcher option', () => { + // The pre-filter that keeps this gate from parsing 1576 of 1597 repository files excludes a + // file mentioning neither word. Both violations require one of them in source, so nothing + // detectable is excluded — the second case proves a violating shape survives the filter. + expect(reasons(' const agent = new Agent({ keepAlive: true })')).toEqual([]) + expect(reasons(` + import { Agent } from 'undici' + const agent = new Agent({}) + `)).toEqual(['constructs an undici agent']) + }) + it('passes on the current tree', () => { expect(scanRepository()).toEqual([]) }) diff --git a/scripts/verify-no-bare-dispatcher.ts b/scripts/verify-no-bare-dispatcher.ts index 57afbb302b..65973d8a04 100644 --- a/scripts/verify-no-bare-dispatcher.ts +++ b/scripts/verify-no-bare-dispatcher.ts @@ -160,6 +160,10 @@ function suppliesDispatcher(member: ts.ObjectLiteralElementLike): boolean { export function findDispatcherViolations(file: string, sourceText: string): DispatcherViolation[] { const posix = file.replaceAll('\\', '/') if (posix.startsWith(DISPATCHER_OWNER)) return [] + // Both violations name one of these two words in source: an agent construction needs a binding + // from the undici module, and the option is a property called `dispatcher`. Parsing the rest of + // the repository anyway made this the slowest gate — 21 of 1597 files survive the filter. + if (!sourceText.includes(AGENT_MODULE) && !sourceText.includes(DISPATCHER_PROPERTY)) return [] const source = ts.createSourceFile(posix, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS) const bound = agentBindings(source) const lines = sourceText.split('\n') From 2a73128d78174a327e933d167daac4488a78e2a1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 28 Aug 2026 16:04:48 +0800 Subject: [PATCH 010/131] docs(net): describe the worker seam as it is, not as a mechanism that exists `installGlobalProxy` claimed each worker thread calls it with a policy its host passed through `workerData`. Nothing does: the two workers this repository ships evaluate model-authored scripts and are deliberately left without a proxy URL that may carry credentials. --- packages/net/http-proxy/src/install.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/net/http-proxy/src/install.ts b/packages/net/http-proxy/src/install.ts index ce95d6ebb4..9aa37d6e75 100644 --- a/packages/net/http-proxy/src/install.ts +++ b/packages/net/http-proxy/src/install.ts @@ -107,8 +107,10 @@ async function createPolicyDispatcher(policy: ProxyPolicy): Promise * every caller that issues a plain `fetch()` is covered without knowing this package exists. A policy * that proxies nothing installs a direct dispatcher and leaves the environment untouched. * - * Worker threads do not inherit the global dispatcher; each one calls this with the policy its host - * passed through `workerData`. + * A worker thread has its own `globalThis` and so its own dispatcher; installing here does not + * reach it. No worker installs one today: both this repository ships — the workflow engine and the + * code runtime — evaluate model-authored scripts, which must not receive a proxy URL that may carry + * credentials. A worker that needs the policy has to be handed one explicitly and install it itself. * * @param policy - the resolved policy to install. * @returns a disposer restoring the previous dispatcher, policy, and environment, then closing the agent. From 03d2c54db172d067df1067b8a09ab49f26016feb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 13:15:40 +0800 Subject: [PATCH 011/131] test: start every suite from an environment with no proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A developer's Clash and a CI runner's squid both export HTTP_PROXY and its siblings. Now that the harness honors them, an ambient value decides test outcomes: this PR has already recorded a proxy's 502 page as a snapshot's expected output, and let a runner's own export stand in for "what the user exported" in an assertion about inherited names. A Vitest setup file clears the proxy names before any suite runs, wired into every configuration that declares a setup. Real-API e2e is cleared too: before proxy support existed every request connected directly and that suite passed, so direct is the environment it is known to work in. `NODE_USE_ENV_PROXY` cannot be cleared this way — Node samples the proxy environment at process start — and the module says so. A proxy application never exports it, and the eight names one does export are fully handled: with all of them set, the affected suites pass. The wiring is what regresses, so that is what the test pins. Configurations are discovered rather than listed, because the web suites carry no setup today and a hand-written list would let one of them gain a setup without gaining this one. `plugin.spec.ts` kept its own copy of the eight names to guard against the machine; it never sets a proxy variable itself, so the setup replaces that entirely. `install.spec.ts` had one assertion waiting on a DNS miss with no deadline, which timed out once under load. --- packages/net/http-proxy/tests/install.spec.ts | 5 +- packages/net/http-proxy/tests/plugin.spec.ts | 26 ++------ scripts/test-proxy-environment.spec.ts | 43 +++++++++++++ scripts/test-proxy-environment.ts | 63 +++++++++++++++++++ vitest.config.ts | 6 +- vitest.e2e.config.ts | 2 +- vitest.expected.config.ts | 2 +- vitest.snapshot.config.ts | 2 +- 8 files changed, 121 insertions(+), 28 deletions(-) create mode 100644 scripts/test-proxy-environment.spec.ts create mode 100644 scripts/test-proxy-environment.ts diff --git a/packages/net/http-proxy/tests/install.spec.ts b/packages/net/http-proxy/tests/install.spec.ts index 3699f4e91e..6b2f1b5e85 100644 --- a/packages/net/http-proxy/tests/install.spec.ts +++ b/packages/net/http-proxy/tests/install.spec.ts @@ -142,7 +142,10 @@ describe('installGlobalProxy', () => { // reuses the HTTP one, tunnelling the scheme the diagnostic told the user stayed direct. const dispose = await installGlobalProxy({ httpProxy: proxyUrl, noProxy: '', source: 'env' }) try { - await expect(fetch('https://refused-scheme.invalid/')).rejects.toThrow() + // The direct path here fails on a DNS miss whose latency is the machine's resolver to decide; + // the deadline bounds it. Either rejection proves the same thing — no CONNECT reached the + // proxy — and a proxied hop would have answered in milliseconds instead. + await expect(fetch('https://refused-scheme.invalid/', { signal: AbortSignal.timeout(1500) })).rejects.toThrow() expect(proxied).toEqual([]) // The same policy still tunnels http, so the empty expectation above is not vacuous. await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY') diff --git a/packages/net/http-proxy/tests/plugin.spec.ts b/packages/net/http-proxy/tests/plugin.spec.ts index b2439c52f1..3059eed8a5 100644 --- a/packages/net/http-proxy/tests/plugin.spec.ts +++ b/packages/net/http-proxy/tests/plugin.spec.ts @@ -1,33 +1,17 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import InvariantRegistry from '@deepseek-ai/dsh-invariants' import { getGlobalDispatcher } from 'undici' +import { PROXY_ENV_NAMES } from '../src/policy.ts' import * as HttpProxy from '../src/index.ts' import * as HttpProxyInvariant from '../src/invariant.ts' const PROXY = 'http://127.0.0.1:7897' -/** - * Every proxy name in both casings. The suite clears all of them so a developer's own exported proxy - * cannot decide the outcome — the lowercase names matter most, since resolution reads those first. - */ -const PROXY_ENV_NAMES = [ - 'http_proxy', 'HTTP_PROXY', 'https_proxy', 'HTTPS_PROXY', - 'no_proxy', 'NO_PROXY', 'all_proxy', 'ALL_PROXY', -] as const - -let saved: Record = {} - -beforeEach(() => { - saved = Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, process.env[name]])) - for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name) -}) - +// `scripts/test-proxy-environment.ts` clears the machine's proxy variables before any suite runs, +// so each test starts from nothing and only has to undo what `withEnv` set. afterEach(() => { - for (const [name, value] of Object.entries(saved)) { - if (value === undefined) Reflect.deleteProperty(process.env, name) - else process.env[name] = value - } + for (const name of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name) }) /** The launcher normally provides a snapshot; without one the plugin reads the process environment. */ diff --git a/scripts/test-proxy-environment.spec.ts b/scripts/test-proxy-environment.spec.ts new file mode 100644 index 0000000000..5e250f03ae --- /dev/null +++ b/scripts/test-proxy-environment.spec.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { PROXY_ENV_NAMES } from '../packages/net/http-proxy/src/policy.ts' +import { clearAmbientProxyEnv, TEST_PROXY_SETUP_FILE, vitestConfigFiles } from './test-proxy-environment.ts' + +describe('ambient proxy environment', () => { + it('clears every name the policy resolver reads, in both casings', () => { + const env: NodeJS.ProcessEnv = { + HTTP_PROXY: 'http://p:1', http_proxy: 'http://p:1', + HTTPS_PROXY: 'http://p:1', https_proxy: 'http://p:1', + ALL_PROXY: 'http://p:1', all_proxy: 'http://p:1', + NO_PROXY: 'example.com', no_proxy: 'example.com', + NODE_USE_ENV_PROXY: '1', + PATH: '/usr/bin', + } + expect(clearAmbientProxyEnv(env)).toHaveLength(PROXY_ENV_NAMES.length + 1) + expect(env).toEqual({ PATH: '/usr/bin' }) + }) + + it('reports only the names that were set, and touches nothing else', () => { + const env: NodeJS.ProcessEnv = { all_proxy: 'http://p:1', HOME: '/home/me' } + expect(clearAmbientProxyEnv(env)).toEqual(['all_proxy']) + expect(env).toEqual({ HOME: '/home/me' }) + }) + + // A runtime assertion that this process is clear would pass either way: importing the module + // above already ran it. What can actually regress is the wiring — a new Vitest project, or a + // config that lists only the invariant host — so that is what this pins. + const declared = vitestConfigFiles() + .map(config => ({ config, slots: readFileSync(config, 'utf8').match(/setupFiles: \[[^\]]*\]/g) ?? [] })) + .filter(entry => entry.slots.length > 0) + + it('finds the configurations that declare a setup at all', () => { + // Guards the discovery itself: a glob that stopped matching would make every case below vacuous. + expect(declared.map(entry => entry.config)).toEqual([ + 'vitest.config.ts', 'vitest.e2e.config.ts', 'vitest.expected.config.ts', 'vitest.snapshot.config.ts', + ]) + }) + + it.each(declared)('$config runs the setup in every setupFiles it declares', ({ slots }) => { + for (const slot of slots) expect(slot).toContain(TEST_PROXY_SETUP_FILE) + }) +}) diff --git a/scripts/test-proxy-environment.ts b/scripts/test-proxy-environment.ts new file mode 100644 index 0000000000..06c4dd9473 --- /dev/null +++ b/scripts/test-proxy-environment.ts @@ -0,0 +1,63 @@ +/** + * Remove the machine's proxy configuration from every Vitest process. + * + * A developer's Clash and a CI runner's squid both export `HTTP_PROXY` and its siblings. Now that + * the harness honors them, an ambient value silently decides test outcomes: a request meant for a + * local fixture server is sent to a proxy that cannot resolve the fixture's hostname, and the + * proxy's error page is recorded as the expected output. The same value also stands in for "what + * the user exported" in any assertion about inherited proxy names. + * + * Clearing here gives every suite one known starting environment, so a test that needs a proxy sets + * exactly the names it means to exercise. Suites that spawn a real `dsh` still clear the child's + * environment themselves — they must hold whether or not a Vitest setup ran. + * + * One name resists this: `NODE_USE_ENV_PROXY`. Node samples the proxy environment when the process + * starts, so deleting the variable from a setup file cannot unbind the built-in `fetch` it already + * configured. A shell that exports it must unset it before running the suite. The names a proxy + * application or a corporate profile actually exports — the eight below — are fully handled, because + * only this repository's own resolver reads them and it runs after this. + * + * Real-API e2e is cleared too. Before proxy support existed every request connected directly and + * that suite passed, so a direct connection is the environment it is known to work in; leaving the + * ambient proxy in place would newly stake it on the proxy reaching the provider. + * @module + */ + +import { globSync } from 'node:fs' +import { resolve } from 'node:path' +import { PROXY_ENV_NAMES } from '../packages/net/http-proxy/src/policy.ts' + +/** The flag a Node process reads before honoring the names above; ambient in the same way. */ +const NODE_PROXY_FLAG = 'NODE_USE_ENV_PROXY' + +/** This module's path as a `setupFiles` entry, so its own wiring test names it once. */ +export const TEST_PROXY_SETUP_FILE = './scripts/test-proxy-environment.ts' + +/** + * Every Vitest configuration in the repository, discovered rather than listed: the web suites carry + * no `setupFiles` today, and a hand-written list would let one of them gain a setup without gaining + * this one. The wiring test asserts only over the configurations that declare a setup at all. + * + * @returns repository-relative config paths, sorted. + */ +export function vitestConfigFiles(): string[] { + return globSync('vitest*.ts', { cwd: resolve(import.meta.dirname, '..') }).sort() +} + +/** + * Delete every proxy name from one environment. + * + * @param env - the environment to clear. + * @returns the names that carried a value, in the order checked. + */ +export function clearAmbientProxyEnv(env: NodeJS.ProcessEnv): string[] { + const cleared: string[] = [] + for (const name of [...PROXY_ENV_NAMES, NODE_PROXY_FLAG]) { + if (env[name] === undefined) continue + cleared.push(name) + Reflect.deleteProperty(env, name) + } + return cleared +} + +clearAmbientProxyEnv(process.env) diff --git a/vitest.config.ts b/vitest.config.ts index ecfd00ded4..3b273bab1d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -149,7 +149,7 @@ const processBoundTests = [ export default defineConfig({ plugins: [pathsPlugin(), standardDecoratorPlugin()], test: { - setupFiles: ['./scripts/test-invariants.ts'], + setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], // .tsx: client component specs (jsdom via per-file @vitest-environment pragma). include: testIncludes, exclude: platformUnsupportedTests, @@ -165,7 +165,7 @@ export default defineConfig({ // MaybeLocal in cjs_lexer::Parse) from worker threads on macOS, // Linux, and Windows. Forked workers avoid that shared thread path. pool: 'forks', - setupFiles: ['./scripts/test-invariants.ts'], + setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], include: testIncludes, exclude: [ ...platformUnsupportedTests, @@ -180,7 +180,7 @@ export default defineConfig({ name: 'process-bound', execArgv: vitestExecArgv, pool: 'forks', - setupFiles: ['./scripts/test-invariants.ts'], + setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], include: processBoundTests, exclude: [ ...platformUnsupportedTests, diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 530a32d745..de04f77bed 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -39,7 +39,7 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, - setupFiles: ['./scripts/test-invariants.ts'], + setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], // apps/cli only, not apps/*: apps/web/tests/*.e2e.ts needs the built // frontend dist and runs under vitest.web.config.ts (the test:web job). include: ['packages/*/*/tests/**/*.e2e.ts', 'apps/cli/tests/**/*.e2e.ts'], diff --git a/vitest.expected.config.ts b/vitest.expected.config.ts index 54ca47ec5b..bf97c7e30e 100644 --- a/vitest.expected.config.ts +++ b/vitest.expected.config.ts @@ -8,7 +8,7 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, - setupFiles: ['./scripts/test-invariants.ts'], + setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], include: [ 'apps/cli/tests/**/*.expected.e2e.ts', ], diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 30e7aa2a63..f8ed5e36ad 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -43,7 +43,7 @@ export default defineConfig({ plugins: [tsconfigPaths({ projects: ['./tsconfig.base.json'] }), standardDecoratorPlugin()], test: { execArgv: vitestExecArgv, - setupFiles: ['./scripts/test-invariants.ts'], + setupFiles: ['./scripts/test-proxy-environment.ts', './scripts/test-invariants.ts'], include: [ 'scripts/session-snapshot-corpus.corpus.ts', // The assembled Web snapshot executes generated client bundles; source From b239db6aebf4e1bc3b66b0e488ed37ff9d890dae Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 29 Aug 2026 13:37:29 +0800 Subject: [PATCH 012/131] test(net): assert the proxy-name contract on a platform that folds case The Windows coverage lane failed on four cases that assume `http_proxy` and `HTTP_PROXY` are separate variables. They are one variable there: `process.env` is case-insensitive, and the launch snapshot folds names for the same reason. A scenario built on "the user set only the lowercase name" cannot exist. Two of them assert a contract rather than a spelling, so they now hold either way: what reaches a child for a scheme the user named is the user's own value and never the derived one, and a nested install carries the active policy's value rather than the outer install's published one. Both read over the pair of names instead of one. The other two are about the case distinction itself. Neither can hold on a folded environment, so each asserts what that platform does instead of skipping: the later entry wins where a preference cannot be expressed, and a diagnostic names the spelling resolution asked for. Both were verified against the folding arm rather than reasoned about. --- packages/net/http-proxy/tests/install.spec.ts | 22 ++++++++++++------- packages/net/http-proxy/tests/policy.spec.ts | 12 ++++++++-- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/net/http-proxy/tests/install.spec.ts b/packages/net/http-proxy/tests/install.spec.ts index 6b2f1b5e85..431cb3af79 100644 --- a/packages/net/http-proxy/tests/install.spec.ts +++ b/packages/net/http-proxy/tests/install.spec.ts @@ -237,9 +237,13 @@ describe('childProxyEnv', () => { const dispose = await installGlobalProxy(proxyAll('example.com')) try { const child = childProxyEnv() - // The published policy invented an HTTPS proxy for this process; the child must not see it. - expect(child.https_proxy).toBe('socks5://127.0.0.1:1080') - expect(child.HTTPS_PROXY).toBeUndefined() + // The published policy derived an HTTPS proxy for this process; the child must not see it. + // Asserted over both casings rather than one: Windows folds the pair into a single variable, + // so which spelling carries the value is the platform's to decide — that it is the user's + // value and never the derived one is not. + const https = [child.https_proxy, child.HTTPS_PROXY] + expect(https).toContain('socks5://127.0.0.1:1080') + expect(https).not.toContain(proxyUrl) expect(child.HTTP_PROXY).toBe(proxyUrl) // The bypass list is the resolved one even though the user set none: it only adds entries, // and without it the child sends its own loopback traffic to a proxy that cannot route it. @@ -312,17 +316,19 @@ describe('childProxyEnv', () => { const disposeInner = await installGlobalProxy(nested) try { const child = childProxyEnv() - // Recording the outer install's published environment as the user's would show the - // lowercase name it wrote and the outer proxy for a scheme the user never named. - expect(child.http_proxy).toBeUndefined() + // The user named no HTTPS proxy, so this scheme carries whichever policy is active. Reading + // the outer install's published environment as the user's would pin it to the outer proxy + // instead — the one discriminator that does not depend on how a platform cases names. expect(child.https_proxy).toBe(nestedUrl) + expect(child.HTTPS_PROXY).toBe(nestedUrl) } finally { await disposeInner() } // Unmounting the inner install must leave the outer one still able to describe that - // environment; clearing the record instead sends every later child the normalized values. + // environment; clearing the record instead makes this an empty object, so every later child + // inherits the normalized values from `process.env` untouched. expect(childProxyEnv().HTTP_PROXY).toBe(proxyUrl) - expect(childProxyEnv().http_proxy).toBeUndefined() + expect(childProxyEnv().https_proxy).toBe(proxyUrl) } finally { await disposeOuter() for (const [name, value] of Object.entries(saved)) { diff --git a/packages/net/http-proxy/tests/policy.spec.ts b/packages/net/http-proxy/tests/policy.spec.ts index b76da665fb..6db288e7a1 100644 --- a/packages/net/http-proxy/tests/policy.spec.ts +++ b/packages/net/http-proxy/tests/policy.spec.ts @@ -15,6 +15,9 @@ function env(values: Record): ReturnType { it('resolves nothing when the environment carries no proxy', () => { const { policy, diagnostics } = resolveProxyPolicy(env({})) @@ -32,7 +35,10 @@ describe('resolveProxyPolicy', () => { it('prefers the lowercase name, matching undici', () => { const { policy } = resolveProxyPolicy(env({ http_proxy: PROXY, HTTP_PROXY: OTHER })) - expect(policy.httpProxy).toBe(PROXY) + // Windows has no such preference to express: the launch snapshot folds names, so the two + // spellings are one variable there and the later entry is simply the value. Asserted rather + // than skipped, so a change to that folding fails here instead of passing unnoticed. + expect(policy.httpProxy).toBe(FOLDS_ENV_CASE ? OTHER : PROXY) }) it('treats a blank lowercase value as unset instead of letting it shadow the uppercase one', () => { @@ -95,7 +101,9 @@ describe('resolveProxyPolicy', () => { const { policy, diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'not a url' })) expect(policy).toEqual(DIRECT_POLICY) expect(diagnostics[0]?.kind).toBe('invalid') - expect(diagnostics[0]?.origin).toBe('HTTP_PROXY') + // The origin names the spelling resolution asked for, which on a folded environment is the + // lowercase one it tries first — the same variable the user set, reported in the other case. + expect(diagnostics[0]?.origin).toBe(FOLDS_ENV_CASE ? 'http_proxy' : 'HTTP_PROXY') }) it('reports a proxy URL whose scheme is neither http(s) nor SOCKS', () => { From a4d44047083d4e30d894b6e4a871d45483cc1da4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sat, 29 Aug 2026 15:35:15 +0800 Subject: [PATCH 013/131] feat(ui-tool): render read_image results as the image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A settled top-level `read_image` call printed its raw attachment object as literal text in the tool card — `{"type":"image","attachment":{…}}` — instead of the image, because no presentation metadata told a client card how to present the reference and the tool-card layer had no image concept. Host: `read_image` declares an `output.presentationMeta` persisting `{ path }` only. The attachment reference deliberately lives in the settled result content — the single record a `tools/post-execute` replacement rewrites — not in `meta`; the id is opaque and provider-owned, checked for existence only. Client: `imageCardModel` derives the card from the call head, the meta path, the result's own image block, and a shape-matched envelope. ToolRow gains an `image` card slot; the `read_image` toolview declares the Tool-owned `tool.call.images` slot as its child and dispatches the gallery through it. ui-chat down-threads the session-authorized loader (`ChatNodeOwnerProps.loadImage`), so the tool layer supplies only derived references plus the loader and never imports an attachment implementation; ui-attachment fills the slot with its message gallery renderer. The card keeps the envelope text below the gallery for the no-attachment-plugin deployment. An image-bearing tool registers a keyed toolview; the generic fallback keeps its flattened text. `read_image` joins the read variant with its own locale title key; both rows share `read-family-row.tsx`. Verification: `read-image.spec.ts` (metadata projection, envelope by shape, reference narrowing, real-execution round trip, rejection branches incl. non-digest ids), `image-card.client.spec.tsx` (derivation, row render site dispatching the slot, keyed registration with the child-slot declaration, empty-slot fallbacks, media-type enum), keyless snapshots (`read-image-gif` added; read-image/-dimension/-reencode updated to the `{path}` meta), five injected-defect negative controls, and a demo GIF recorded from this PR's head through the official image-capable model. --- ...26-08-10-minimal-read-image-tool.i18n.yaml | 4 +- .../2026-08-10-minimal-read-image-tool.md | 2 +- .../2026-08-10-minimal-read-image-tool.zh.md | 2 +- ...26-08-20-tool-card-image-results.i18n.yaml | 6 + .../2026-08-20-tool-card-image-results.md | 57 +++ .../2026-08-20-tool-card-image-results.zh.md | 57 +++ ...026-08-27-port-tool-owned-render.i18n.yaml | 6 + .../2026-08-27-port-tool-owned-render.md | 35 ++ .../2026-08-27-port-tool-owned-render.zh.md | 35 ++ docs/subsystems/slots.i18n.yaml | 4 +- docs/subsystems/slots.md | 1 + docs/subsystems/slots.zh.md | 1 + .../client/ui-attachment/README.i18n.yaml | 4 +- packages/client/ui-attachment/README.md | 6 +- packages/client/ui-attachment/README.zh.md | 6 +- packages/client/ui-attachment/package.json | 6 +- .../client/ui-attachment/src/client/index.ts | 9 +- .../ui-attachment/tests/plugin.client.spec.ts | 6 + packages/client/ui-attachment/tsconfig.json | 3 + .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 5 +- .../ui-chat/src/client/chat/ChatView.tsx | 1 + .../ui-chat/src/client/contract/slots.ts | 7 + .../ui-conversation/src/client/locales.ts | 2 + packages/client/ui-tool/README.i18n.yaml | 4 +- packages/client/ui-tool/README.md | 6 +- packages/client/ui-tool/README.zh.md | 6 +- packages/client/ui-tool/package.json | 3 +- packages/client/ui-tool/src/client/apply.ts | 2 + .../ui-tool/src/client/contract/slots.ts | 31 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 16 +- .../client/tool/components/ToolRow.module.css | 23 ++ .../src/client/tool/components/ToolRow.tsx | 133 ++++--- .../client/tool/models/image-card-model.ts | 232 +++++++++++ .../src/client/tool/models/tool-call-model.ts | 6 + .../client/tool/toolviews/read-family-row.tsx | 59 +++ .../client/tool/toolviews/read-image-row.tsx | 61 +++ .../src/client/tool/toolviews/read-row.tsx | 28 +- .../tests/coverage-tails.client.spec.tsx | 1 + .../ui-tool/tests/diff-card.client.spec.tsx | 4 +- .../ui-tool/tests/image-card.client.spec.tsx | 371 ++++++++++++++++++ .../ui-tool/tests/read-card.client.spec.tsx | 3 +- .../ui-tool/tests/search-card.client.spec.tsx | 1 + .../tests/terminal-card.client.spec.tsx | 1 + .../tests/tool-call-tree.client.spec.tsx | 1 + .../ui-tool/tests/tool-row.client.spec.tsx | 1 + .../ui-tool/tests/web-card.client.spec.tsx | 2 +- packages/client/ui-tool/tsconfig.json | 3 + .../tests/workflow-run.client.spec.tsx | 1 + .../tests/image-loadable.spec.ts | 18 +- .../src/client/slot-catalog.ts | 88 +++-- packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 2 +- packages/fs/tool-fs/README.zh.md | 2 +- packages/fs/tool-fs/src/read-image.ts | 9 + packages/fs/tool-fs/tests/read-image.spec.ts | 68 ++++ pnpm-lock.yaml | 6 + .../read-image-dimension/session.jsonl | 2 +- .../session/read-image-gif/session.jsonl | 29 ++ snapshots/session/read-image-gif/snapshot.yml | 7 + .../session/read-image-gif/workspace/red.gif | Bin 0 -> 42 bytes .../session/read-image-reencode/session.jsonl | 2 +- snapshots/session/read-image/session.jsonl | 2 +- 62 files changed, 1366 insertions(+), 137 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md create mode 100644 .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml create mode 100644 .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md create mode 100644 .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md create mode 100644 packages/client/ui-tool/src/client/tool/models/image-card-model.ts create mode 100644 packages/client/ui-tool/src/client/tool/toolviews/read-family-row.tsx create mode 100644 packages/client/ui-tool/src/client/tool/toolviews/read-image-row.tsx create mode 100644 packages/client/ui-tool/tests/image-card.client.spec.tsx create mode 100644 snapshots/session/read-image-gif/session.jsonl create mode 100644 snapshots/session/read-image-gif/snapshot.yml create mode 100644 snapshots/session/read-image-gif/workspace/red.gif diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml index 4c276c6b0d..0b46846ce1 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md -2026-08-10-minimal-read-image-tool.md: 8880032b2648846df679ea8fa3301d182a95c06b -2026-08-10-minimal-read-image-tool.zh.md: aec34e19fc58037b031f7d4116d2fa664b2b45b5 +2026-08-10-minimal-read-image-tool.md: 67a738e71901fb251b8e9e167d52bac4f2f3720a +2026-08-10-minimal-read-image-tool.zh.md: 7a468372542a45088946bdaac7f1869c7a1909ee diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md index 8880032b26..67a738e719 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md @@ -29,4 +29,4 @@ Both image-reading operations live in `dsh-tool-fs` and publish ordinary logged - The tools refuse execution on a text-only route, while existing images in session history are represented by request-local placeholders. - Repeated image results accumulate request cost until request projection or compaction removes them; content addressing deduplicates durable bytes. -- The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages. +- The tool-result card now renders the image itself through the browser's `tool.call.images` slot (see [the tool-card image results note](2026-08-20-tool-card-image-results.md)); a UI without the attachment presentation plugin shows the result's envelope text. diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md index aec34e19fc..7a46837254 100644 --- a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md @@ -29,4 +29,4 @@ Status: implemented - 工具在纯文本路由上拒绝执行,而会话历史中已经存在的图片会由请求期占位符表示。 - 重复的图片结果会累积请求成本,直到请求投影或压缩将其移除;内容寻址只去重持久字节。 -- 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理。 +- 工具结果卡片现在经由浏览器的 `tool.call.images` 槽位渲染图像本身(见 [tool-card image results 笔记](2026-08-20-tool-card-image-results.zh.md));未组合附件呈现插件的 UI 显示结果的信封文本。 diff --git a/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml new file mode 100644 index 0000000000..303334fb75 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md +2026-08-20-tool-card-image-results.md: c2f069a6c8eb6d7be48c80fca846bcb2c2be1dd3 +2026-08-20-tool-card-image-results.zh.md: 018a4f0e20f254285189965102fbb4390c160e1c diff --git a/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md new file mode 100644 index 0000000000..c2f069a6c8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md @@ -0,0 +1,57 @@ +# Agent Note: Tool-card image results + +Status: implemented + +English | [中文](2026-08-20-tool-card-image-results.zh.md) + +## Problem + +A settled `read_image` call rendered its raw attachment object as literal text in the tool card — `{"type":"image","attachment":{"attachmentId":"sha256:…","mediaType":"image/png","bytes":24588,"width":1496,…}}` — instead of showing the image. + +Two independent gaps produced that. `read_image` declared no `output.presentationMeta`, so no presentation metadata told a client card how to present the reference — the tool card printed the raw result content as text. Separately, the tool-card layer had no image concept: `packages/client/ui-tool/src` contained no occurrence of `image` or `attachment`, and `ToolRow`'s card slots were terminal, diff, read, search, and web. + +The rendering capability already existed, but only on the message path. `MessageImages` draws durable image groups for user and assistant history through the `conversation.message.images` slot. That asymmetry explains a confusing observation: a **nested** `read_image` displayed correctly, because `execute` defers a real user message for a nested call, while a top-level call — which returns the image only as tool-result content — did not. + +## Decision + +**Host.** `read_image` gains an `output.presentationMeta` that persists `{ path }` — the path only. + +The attachment reference is deliberately not persisted there. The settled `content` already carries the image block with the complete reference, and that block is what a `tools/post-execute` hook replaces when it legitimately rewrites a result. A second copy in `meta` would therefore be a duplicate record of one fact, and a stale one exactly when the content changed — the card would keep showing an image the result no longer returns. The path is the one fact the content does not carry as a structured field: the model-facing envelope embeds the backend-resolved path as text, and the client never parses that text. + +No `presentResult`, and no new member of the closed `ToolResultView` union. Client cards derive from raw event fields, and host `presentCall`/`presentResult` values never enter the client ([ui-tool README](../../../../packages/client/ui-tool/README.md)), so a result-view arm would have been a public type extension with no consumer. + +**Client.** `imageCardModel` derives the card the way every other first-party card does: `parsedToolCall` validates the call head and its `file_path`, `block.meta` supplies the path, the attachment reference is narrowed out of the result's own image block, and the envelope is located in the same content. It matches its own envelope by shape rather than using `singleResultText`, because that helper accepts only a lone text block while an image read returns `[text envelope, image block]` — matching by shape also means content another layer prepended is never mistaken for the envelope. + +The narrowing checks the attachment id for existence only. The id is opaque and provider-owned: the local store mints content addresses, but consumers must neither parse that representation nor assume its shape, and a provider may change it without notice. Pattern-matching the local form would reject a legitimate id from an alternative store and silently degrade every image card in that deployment. + +`ToolRow` gains an `image` card slot, and `read_image` gets a keyed toolview that declares the Tool-owned `tool.call.images` slot as its child and renders the gallery through it. The tool layer never loads or authorizes anything: the row supplies only the references it derived from the result plus the `loadImage` loader the chat node now passes down (`ChatNodeOwnerProps.loadImage`), and the attachment presentation plugin fills the slot with the same gallery it uses for message images. An image-bearing tool therefore registers a keyed toolview (`read_image` is the template for the row assembly and card model; the `tool.call.images` child declaration is not reusable verbatim, because a slot is declared by exactly one entry); the generic fallback keeps its flattened text. + +The card keeps the derived envelope text below the gallery. That is not redundancy: `tool.call.images` renders nothing in a deployment without the attachment presentation plugin, and that empty gallery must not leave a blank card — measured, not assumed: a slot returning `null` rendered an empty container with no visible text. + +`read_image` joins the `read` variant and gets its own locale title key. Left unclassified it fell to `others`, which titles the row generically and derives no `filePath` (only read/write/edit variants do), so the openable path the row advertises would never have been openable. + +`read` and `read_image` are the same single-file card row with different card material, so their shared assembly lives in `read-family-row.tsx` rather than being copied. + +## Alternatives considered + +- **Add `card: 'image'` arm to `ToolResultView` and a `presentResult`.** This is what the first version did. Client cards derive from raw events and host presentation values never reach the client, so the arm had no consumer — an extension of a closed public union that nothing read. Dropped in favour of `presentationMeta` alone. +- **Pass a rendering closure down (the `renderMessageImages` pattern).** The next version reused `ChatNodeOwnerProps.renderMessageImages` as a `renderImages` owner prop, mirroring what `AssistantMarkdown` and the message rows do. Review rejected it: the client rule forbids new ReactNode-valued owner props, and the compliant shape is a slot the tool layer itself declares. With `loadImage` down-threaded from the chat node, the row renders `tool.call.images` directly and no rendering capability crosses the owner boundary. +- **Render the image on the generic fallback too.** The slot design cannot: a slot is declared by exactly one entry, and the fallback component is not a registered entry, so it has no dispatch seat for a child it did not declare. The keyed row is the only image render site; future image tools register their own. +- **Use `singleResultText` like the read card.** It accepts only a lone text block by design, and an image read returns two, so the card matches its own envelope shape instead. +- **Persist the reference in `meta` as well.** The first version did, and it read the card from there. Review pointed out the duplication, and the recorded log confirmed it: `meta.image` and the content block's `attachment` were byte-identical. Reading from the content instead leaves one record and follows a post-execute replacement. +- **Validate the attachment id against `sha256:`.** Tried, then reverted: it contradicts the documented opacity of `AttachmentId` and would break any deployment whose store mints another shape. +- **Give the image card its own primitive in `ui-primitives`.** Rejected as duplication — the message gallery's fit rules, crop anchors, and lightbox are the behavior a card needs. + +## Verification + +`read-image.spec.ts` covers the metadata projection, the omitted display name, and a real execution whose persisted reference matches what the attachment store committed. `image-card.client.spec.tsx` covers the derivation from metadata and envelope, path relativization, opaque ids from alternative stores, every rejection branch of the defensive narrowing, the running/error/nested declines, the keyed row render site dispatching `tool.call.images` with the loader, keyed registration with the child-slot declaration, and the empty-slot fallback. + +Negative controls were run against each assertion group before it was kept: removing the variant classification, disabling the image render branch, mistyping the registrant key, restoring the `sha256:` id pattern, and pointing the card's text back at the row's flattened output each turned the intended assertion red. + +## Consequences + +A top-level `read_image` now renders as the image, matching what a nested call already did, and the tool card gains an image kind. The image kind is not automatic from the metadata alone: the card also requires the `tool.call.images` slot to be filled (the attachment presentation plugin) and a keyed toolview for the tool, because the model narrows the call head to `read_image` and the slot is rendered from a declared child entry. + +The persisted presentation metadata adds one small `{ path }` record per image read to the session log. The attachment reference is not in the log as metadata at all — it lives in the settled result content's image block — and the image bytes themselves are never logged, because the store is content-addressed and the block carries only the attachment id. + +Because the card derives from `block.meta` plus the settled content, a session logged before this change carries no image metadata and replays as the generic text card. That is the documented fallback for every raw-event-derived card, not a special case here. diff --git a/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md new file mode 100644 index 0000000000..018a4f0e20 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md @@ -0,0 +1,57 @@ +# Agent Note:工具卡片的图像结果 + +Status: implemented + +[English](2026-08-20-tool-card-image-results.md) | 中文 + +## 问题 + +已结算的 `read_image` 调用在工具卡片里把原始附件对象当字面文本渲染出来——`{"type":"image","attachment":{"attachmentId":"sha256:…","mediaType":"image/png","bytes":24588,"width":1496,…}}`——而不是显示图像本身。 + +这由两个彼此独立的缺口造成。`read_image` 没有声明 `output.presentationMeta`,因此没有任何呈现元数据告诉客户端卡片如何展示引用——工具卡片把原始结果内容当作文本打了出来。另一方面,工具卡片层完全没有图像概念:`packages/client/ui-tool/src` 中 `image` 和 `attachment` 一次都没出现,而 `ToolRow` 的卡片槽只有 terminal、diff、read、search、web。 + +渲染能力其实已经存在,但只接在消息路径上。`MessageImages` 通过 `conversation.message.images` 槽位为用户与助手历史绘制持久图像组。这个不对称解释了一个容易困惑的现象:**嵌套的** `read_image` 能正确显示,因为嵌套调用时 `execute` 会 defer 一条真正的用户消息;而顶层调用只把图像作为工具结果内容返回,就不显示。 + +## 决定 + +**宿主侧。** `read_image` 获得只持久化 `{ path }` 的 `output.presentationMeta`——仅路径一项。 + +附件引用有意不写在那里。已结算的 `content` 本身就带着含完整引用的 image 块,而当 `tools/post-execute` 钩子合法重写结果时,被替换的正是那个块。因此在 `meta` 里再存一份就是同一事实的重复记录,且恰恰在内容变化时变成过期副本——卡片会继续显示结果已不再返回的图像。路径是 content 唯一不作为结构化字段携带的事实:面向模型的信封把后端解析出的路径写成文本,而客户端从不解析那段文本。 + +不加 `presentResult`,也不给封闭的 `ToolResultView` 联合新增成员。客户端卡片从原始 event 字段派生,宿主的 `presentCall`/`presentResult` 值永不进入客户端(见 [ui-tool README](../../../../packages/client/ui-tool/README.zh.md)),因此新增一个 result-view 分支等于扩展一个无人读取的封闭公共联合。 + +**客户端侧。** `imageCardModel` 按其他所有第一方卡片的方式派生:`parsedToolCall` 校验调用头与其 `file_path`,`block.meta` 提供路径,附件引用从结果自己的 image 块中防御式 narrow 出来,信封在同一内容中定位。它按形状匹配自己的信封而不用 `singleResultText`,因为那个 helper 只接受单个文本块,而图像读取返回 `[text envelope, image block]`——按形状匹配同时意味着其他层前置的内容永远不会被误认为信封。 + +该 narrowing 只检查附件 id 是否存在。id 是不透明且由提供方拥有的:本地存储铸造内容地址,但消费者既不得解析该表示、也不得假定其形状,且提供方可以不经通知改变它。按本地形式做模式匹配会拒绝替代存储铸造的合法 id,并让该部署中每个图像卡片静默降级。 + +`ToolRow` 获得 `image` 卡片槽,`read_image` 获得按 key 注册的 toolview,并在其 registration 上把 Tool 自有的 `tool.call.images` 槽位声明为子槽,通过它渲染图库。工具层自己既不加载也不授权:这一行只提供从结果派生出的引用,以及聊天节点新下传的 `loadImage` loader(`ChatNodeOwnerProps.loadImage`),附件呈现插件用与消息图像相同的图库填充该槽位。因此携带图像的工具需要注册按 key 的 toolview(`read_image` 是行装配与 card model 的模板;`tool.call.images` 子槽声明不能逐字复用,因为一个槽位只能由一个 entry 声明);generic fallback 保留压平文本。 + +卡片在图库下方保留派生出的信封文本。这不是冗余:在未组合附件呈现插件的部署里 `tool.call.images` 什么都不渲染,而空图库不能留下空白卡片——这是实测而非假设:用一个返回 `null` 的槽位探测,渲染出的是空容器,没有任何可见文本。 + +`read_image` 归入 `read` variant 并获得自己的 locale 标题 key。不分类时它落到 `others`,标题变成通用文案且不派生 `filePath`(只有 read/write/edit variant 会派生),于是该行声称可点击打开的路径永远不可点击。 + +`read` 与 `read_image` 是同一种单文件卡片行、只是卡片材料不同,因此它们共享的装配放在 `read-family-row.tsx` 而不是复制一份。 + +## 考虑过的备选方案 + +- **给 `ToolResultView` 加 `card: 'image'` 分支并实现 `presentResult`。** 第一版就是这么做的。客户端卡片从原始 event 派生、宿主呈现值不进入客户端,因此该分支没有消费方——等于扩展一个无人读取的封闭公共联合。改为只用 `presentationMeta`。 +- **向下传递渲染闭包(`renderMessageImages` 模式)。** 下一版复用了 `ChatNodeOwnerProps.renderMessageImages` 作为 `renderImages` owner prop,与 `AssistantMarkdown` 和消息行的做法一致。review 拒绝了它:客户端规则禁止新增 ReactNode-valued owner props,合规形态是工具层自己声明的槽位。把 `loadImage` 从聊天节点下传后,这一行直接渲染 `tool.call.images`,不再有任何渲染能力穿过 owner 边界。 +- **在 generic fallback 上也渲染图像。** 槽位设计做不到:一个槽位只能由一个 entry 声明,而 fallback 组件不是已注册 entry,没有为未声明的子槽提供 dispatch 席位。按 key 的行是唯一图像渲染点;将来的图像工具注册自己的行。 +- **像 read 卡片那样用 `singleResultText`。** 它按设计只接受单个文本块,而图像读取返回两块,因此卡片改为按自己的信封形状匹配。 +- **在 `meta` 里也持久化引用。** 第一版就是这么做的,卡片也从那里读取。review 指出了这处重复,实录日志也证实了:`meta.image` 与 content 中 image 块的 `attachment` 逐字节相同。改从 content 读取后只剩一份记录,并且会跟随 post-execute 的替换。 +- **按 `sha256:` 校验附件 id。** 试过后撤回:它与 `AttachmentId` 文档化的不透明性相矛盾,并会让任何采用其他 id 形状的部署失效。 +- **在 `ui-primitives` 里给图像卡片做专属 primitive。** 作为重复实现否决——消息图库的适配规则、裁剪锚点和灯箱正是卡片需要的行为。 + +## 验证 + +`read-image.spec.ts` 覆盖元数据投影、省略显示名,以及一次真实执行——其持久化的引用与附件存储实际提交的一致。`image-card.client.spec.tsx` 覆盖从元数据与信封的派生、路径相对化、来自替代存储的不透明 id、防御式 narrowing 的每个拒绝分支、running/error/嵌套三种拒绝、按 key 的行渲染点(携带 loader 分发 `tool.call.images`)、带子槽声明的按 key 注册,以及空槽位降级。 + +每组断言在保留之前都跑过负例:移除 variant 分类、让图像渲染分支失效、把 registrant 的 key 打错、恢复 `sha256:` id 模式、把卡片文本指回行的压平输出——每一项都让目标断言变红。 + +## 后果 + +顶层 `read_image` 现在渲染为图像,与嵌套调用早已具备的行为一致;工具卡片获得一种图像种类。图像种类并不由元数据单独自动产生:卡片还要求 `tool.call.images` 槽位被填充(附件呈现插件),并且工具注册按 key 的 toolview——因为模型把调用头收窄到 `read_image`,槽位只能从声明的子 entry 渲染。 + +持久化的呈现元数据为每次图像读取在会话日志中增加一条很小的 `{ path }` 记录。附件引用完全不在元数据里——它位于已结算结果 content 的 image 块中;图像字节本身从不进入日志,因为存储是内容寻址的,块里只携带附件 id。 + +由于卡片从 `block.meta` 派生,本次改动之前记录的会话没有图像元数据,会以通用文本卡片重放。这是每个从原始 event 派生的卡片都遵循的既有降级路径,不是这里的特例。 diff --git a/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml new file mode 100644 index 0000000000..0c57f66e04 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md +2026-08-27-port-tool-owned-render.md: 4ab27142fdc2d13d7d152c21c2f246e340fb4711 +2026-08-27-port-tool-owned-render.zh.md: 9b5ece1a222551a308f6008e316fb4c251c36d88 diff --git a/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md new file mode 100644 index 0000000000..4ab27142fd --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.md @@ -0,0 +1,35 @@ +# Agent Note: Port tool-owned render into current DSH APIs + +Status: proposed + +English | [中文](2026-08-27-port-tool-owned-render.zh.md) + +## Problem + +The `dsh-tool-owned-render` prototype (`Chinesezjc/dsh-tool-owned-render`) ships tool-owned render registrants for `read`, `bash`, `write`/`edit`, `grep`/`glob`, and `web_search`/`web_fetch`, written against an older API where `ToolCallBlock` exposed `callView` / `resultView` and the client received host `presentResult` output. Current master derives client cards from raw `block.call` / `block.content` / `block.meta`, and `ctx.slots` requires the `@deepseek-ai/dsh-client-ui-renderer/client` module augmentation. A direct merge of the prototype does not typecheck, so its registrants cannot ship without a port. + +## Proposal + +- Add `packages/client/tool-owned-render` as a workspace package. +- Port the `read`, `bash`, `write`/`edit`, `grep`/`glob`, and `web_search`/`web_fetch` registrants to derive from current `ToolCallBlock` fields. +- Add a `read_image` registrant using the same ToolCard/Segment primitives. +- Wire `ctx.slots` type augmentation through `dsh-client-ui-renderer`. +- Keep PR #2828 mergeable while this port proceeds separately. + +## Alternatives considered + +- **Merge the prototype and fix its type errors in place** — rejected: every registrant would have to be re-derived from the current `ToolCallBlock` fields anyway, so the port is the same work with the obsolete `callView` / `resultView` contract already gone. +- **Fold the port into PR #2828** — rejected: the image card is one feature with a defined scope, and a second package plus five more registrants would enlarge the review surface of an already large PR. + +## Acceptance criteria + +- `packages/client/tool-owned-render` exists as a workspace package. +- The ported registrants derive card state from current `ToolCallBlock` fields and typecheck on master. +- A `read_image` registrant renders through the same primitives as `read`. +- The `ctx.slots` type augmentation resolves through `dsh-client-ui-renderer`. +- PR #2828 merges independently of this port. + +## Risks + +- The port may not reproduce the prototype's exact visual output, because the current card primitives differ from the old `callView` / `resultView` contract. +- API drift while the port proceeds can stale this proposal; the acceptance criteria are re-checked against master at port time. diff --git a/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md new file mode 100644 index 0000000000..9b5ece1a22 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-27-port-tool-owned-render.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 把 tool-owned render 移植到当前 DSH API + +状态:proposed + +[English](2026-08-27-port-tool-owned-render.md) | 中文 + +## 问题 + +`dsh-tool-owned-render` 原型(`Chinesezjc/dsh-tool-owned-render`)带有 `read`、`bash`、`write`/`edit`、`grep`/`glob`、`web_search`/`web_fetch` 的 tool-owned render 注册项,基于旧 API 编写:`ToolCallBlock` 暴露 `callView` / `resultView`,客户端能拿到 host `presentResult` 输出。当前 master 从原始 `block.call` / `block.content` / `block.meta` 推导客户端卡片,`ctx.slots` 也需要 `@deepseek-ai/dsh-client-ui-renderer/client` 模块增强。直接合并原型不能通过类型检查,因此这些注册项不经移植无法发布。 + +## 提案 + +- 新增 `packages/client/tool-owned-render` workspace 包。 +- 把 `read`、`bash`、`write`/`edit`、`grep`/`glob`、`web_search`/`web_fetch` 注册项移植到从当前 `ToolCallBlock` 字段推导。 +- 增加 `read_image` 注册项,使用同一套 ToolCard/Segment 原语。 +- 通过 `dsh-client-ui-renderer` 接通 `ctx.slots` 类型增强。 +- 移植单独推进,保持 PR #2828 可合并。 + +## 已考虑的替代方案 + +- **直接合并原型并就地修复类型错误** — 否决:每个注册项反正都要按当前 `ToolCallBlock` 字段重新推导,移植就是同一份工作,只是旧的 `callView` / `resultView` 契约已不存在。 +- **把移植并入 PR #2828** — 否决:image 卡片是一个范围明确的单一功能,再加一个新包和五个注册项会扩大本已很大的 PR 的审查面。 + +## 验收标准 + +- `packages/client/tool-owned-render` 作为 workspace 包存在。 +- 移植后的注册项从当前 `ToolCallBlock` 字段推导卡片状态,并在 master 上通过类型检查。 +- `read_image` 注册项与 `read` 使用同一套原语渲染。 +- `ctx.slots` 类型增强通过 `dsh-client-ui-renderer` 解析。 +- PR #2828 独立于本移植合并。 + +## 风险 + +- 移植可能无法复现原型的精确视觉输出,因为当前卡片原语与旧的 `callView` / `resultView` 契约不同。 +- 移植推进期间 API 继续漂移会使本提案过时;验收标准在移植时按当时的 master 重新核对。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index d0c94878b2..1c96cdce9a 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/slots.md -slots.md: 8e115e30aed68e543eca2f1aac6e28ad9f57cf73 -slots.zh.md: 3894b69d8d020b4bb67ce325d389ab3f20cfcc9a +slots.md: a37374ff11f9d460a2566a7a30f8318e69cbfa0b +slots.zh.md: 09329b1f299f4912490f888efa3146ec0ce11e28 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 8e115e30ae..a37374ff11 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -135,6 +135,7 @@ root │ │ │ ├─ conversation.chat.commandview │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images │ │ │ └─ tool.view.cordis │ │ ├─ conversation.message.images │ │ └─ conversation.trajectory.images diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 3894b69d8d..09329b1f29 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -135,6 +135,7 @@ root │ │ │ ├─ conversation.chat.commandview │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview +│ │ │ ├─ tool.call.images │ │ │ └─ tool.view.cordis │ │ ├─ conversation.message.images │ │ └─ conversation.trajectory.images diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 5e0e50e06a..8fa8015caf 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md -README.md: 9fa03432b43686dd55af494640708a8bf0983f9a -README.zh.md: 48e467280bfb83b2f4341f0e4c833b0b44cdce18 +README.md: 47faee79e1e638927dfbff117e7ab8ea18360363 +README.zh.md: 483484e2bec057d422b03440a1386682d6bebb9f diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 9fa03432b4..47faee79e1 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders everything the conversation UI shows about attachments: pending draft images under the composer, a full-viewport drop invitation, durable images in Chat and Trajectory, and a lightbox for the original image. It is a pure presentation layer — attachment data, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style image experience; non-image files have no surface here. +This package renders everything the conversation UI shows about attachments: pending draft images under the composer, a full-viewport drop invitation, durable images in Chat, Trajectory, and Tool results, and a lightbox for the original image. It is a pure presentation layer — attachment data, image loading, and callbacks come from the conversation package through declared slots. Choose it for the DeepSeek Chat-style image experience; non-image files have no surface here. ## Table of Contents @@ -25,7 +25,7 @@ This package renders everything the conversation UI shows about attachments: pen ## Use this package -Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the draft-image rail with per-image remove and click-to-open, the drop overlay with its limits line, message images sized by count, and the Escape/mask/close lightbox. +Mount this plugin alongside [`ui-conversation`](../ui-conversation/README.md) (and [`ui-tool`](../ui-tool/README.md) for the tool-result gallery); it waits for the conversation package's slot declarations and registers its surfaces into them. Users then see the draft-image rail with per-image remove and click-to-open, the drop overlay with its limits line, message images sized by count, the tool card's gallery, and the Escape/mask/close lightbox. ### Draft images @@ -47,7 +47,7 @@ While a file drag is over the page, the full-viewport overlay announces the drop
Implementation internals — click to expand -The plugin waits for `conversation.input.attachments`, `conversation.message.images`, and `conversation.trajectory.images` through `ctx.slots.inject`. It then registers the composer rail, document drop target, shared history gallery for Chat and Trajectory, and original-image lightbox. The presentation components are pure props: the conversation slot owner supplies attachment data, image loading, callbacks, and the locale translator; the package entry exports no components. +The plugin waits for `conversation.input.attachments`, `conversation.message.images`, `conversation.trajectory.images`, and `tool.call.images` through `ctx.slots.inject`. It then registers the composer rail, document drop target, shared history gallery for Chat, Trajectory, and Tool results, and original-image lightbox. The presentation components are pure props: the slot owner supplies attachment data, image loading, callbacks, and the locale translator; the package entry exports no components. | File | Role | |---|---| diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 48e467280b..483484e2be 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包渲染对话 UI 中与附件相关的一切:composer 下的待发送草稿图片、全视口拖放邀请层、Chat 与 Trajectory 中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的图片体验时选择它;非图片文件在此没有任何表面。 +本包渲染对话 UI 中与附件相关的一切:composer 下的待发送草稿图片、全视口拖放邀请层、Chat、Trajectory 与工具结果中的持久图片,以及查看原图的灯箱。它是纯呈现层——附件数据、图片加载与回调都经声明槽位来自 conversation 包。需要 DeepSeek Chat 风格的图片体验时选择它;非图片文件在此没有任何表面。 ## 目录 @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 [`ui-conversation`](../ui-conversation/README.zh.md) 一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户随即看到:带逐图删除与点击打开的草稿图片栏、带上限说明的拖放遮罩、按数量定尺寸的消息图片,以及支持 Escape/遮罩/关闭按钮的灯箱。 +与 [`ui-conversation`](../ui-conversation/README.zh.md)(以及工具结果图库所需的 [`ui-tool`](../ui-tool/README.zh.md))一起挂载本插件;它等待 conversation 包的槽位声明,并把自身表面注册进这些槽位。用户随即看到:带逐图删除与点击打开的草稿图片栏、带上限说明的拖放遮罩、按数量定尺寸的消息图片、工具卡片的图库,以及支持 Escape/遮罩/关闭按钮的灯箱。 ### 草稿图片 @@ -47,7 +47,7 @@ kind: "package-reference"
实现细节——点击展开 -插件通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images` 与 `conversation.trajectory.images`。随后它注册 composer rail、文档拖放目标、供 Chat 和 Trajectory 共用的历史图片 gallery,以及原图灯箱。呈现组件保持纯 props:conversation 槽位持有方提供附件数据、图片加载、回调与语言包翻译器;包入口不导出任何组件。 +插件通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images`、`conversation.trajectory.images` 与 `tool.call.images`。随后它注册 composer rail、文档拖放目标、供 Chat、Trajectory 与工具结果共用的历史图片 gallery,以及原图灯箱。呈现组件保持纯 props:槽位持有方提供附件数据、图片加载、回调与语言包翻译器;包入口不导出任何组件。 | 文件 | 职责 | |---|---| diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 42d140be37..2273acf709 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -35,7 +35,8 @@ "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-renderer", - "@deepseek-ai/dsh-client-ui-trajectory" + "@deepseek-ai/dsh-client-ui-trajectory", + "@deepseek-ai/dsh-client-ui-tool" ], "platform": "web" } @@ -61,7 +62,8 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0", - "@deepseek-ai/dsh-attachment": "workspace:^" + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 8fe94f64ee..62deeebfc3 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -1,8 +1,9 @@ -/** Browser attachment plugin: fills conversation's composer and message-image slots. */ +/** Browser attachment plugin: fills conversation's composer and image slots. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-tool/client' import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client' import { ComposerAttachments } from './ComposerAttachments.tsx' import { MessageImages } from './MessageImages.tsx' @@ -24,4 +25,10 @@ export function apply(ctx: ClientContext): void { name: 'conversation.trajectory.images', locale: 'conversation', }, MessageImages)) + // The tool image gallery reuses the message gallery renderer: its owner + // carries the same images/loadImage/align share the message arm does. + ctx.slots.inject('tool.call.images', () => ctx.slots.register({ + name: 'tool.call.images', + locale: 'conversation', + }, MessageImages)) } diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index 21d84953de..a83b33e8e0 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -15,6 +15,7 @@ async function bench() { 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, 'conversation.message.images': { kind: 'single', scope: 'session' }, 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, + 'tool.call.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) const fiber = ctx.plugin({ inject: [...inject], apply }) @@ -42,11 +43,16 @@ describe('attachment plugin', () => { locale: 'conversation', component: MessageImages, }]) + expect(ctx.slots.entries('tool.call.images')).toMatchObject([{ + locale: 'conversation', + component: MessageImages, + }]) await fiber.dispose() expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0) expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0) expect(ctx.slots.entries('conversation.trajectory.images')).toHaveLength(0) + expect(ctx.slots.entries('tool.call.images')).toHaveLength(0) }) }) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index b2783b20e9..dd526af933 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-tool" + }, { "path": "../ui-trajectory" }, diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 00eb9fd70d..5e74943b8a 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -80,7 +80,7 @@ function turnProcessLayout( export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, historyIncomplete, compactTranscript, selectedCallId, cwd, openFile, inspectCall, forkAt, - renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t, + loadImage, renderMessageImages, fileMentions, useChat, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { const node = useChat(snapshot => snapshot.nodes.get(nodeKey)) const processSignature = useChat((snapshot) => { @@ -181,12 +181,13 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ openFile, inspectCall, forkAt, + loadImage, renderMessageImages, fileMentions, turnProcess, }, [ node, selectedCallId, cwd, openFile, inspectCall, forkAt, - renderMessageImages, fileMentions, turnProcess, + loadImage, renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null const location = routedNode.location diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index b5f0313f15..a5dd412ea8 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -606,6 +606,7 @@ export function ChatView({ openFile={requestOpenFile} inspectCall={inspectCall} forkAt={forkAt} + loadImage={loadImage} renderMessageImages={renderMessageImages} fileMentions={fileMentions} renderSlot={renderSlot} diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 307d8f703b..0cd01495b4 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -66,6 +66,13 @@ export interface ChatNodeOwnerProps { openFile: (path: string) => void inspectCall: (callId: ToolCallId) => void forkAt: (seq: number) => void + /** + * Session-authorized image loader, down-threaded from the Chat view so a + * chat-node renderer can render the attachment presentation slot directly + * with only the durable references plus this loader, instead of receiving a + * rendering closure. + */ + loadImage: MessageImageLoader renderMessageImages: RenderMessageImages fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined /** Turn-process state when this Node belongs to a projected Turn. */ diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 9eaf2a82a0..9d9b26253a 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -102,6 +102,7 @@ export const zh = { 'tool.title.stopCordis': '停止 Cordis 插件', 'tool.title.removeCordis': '移除 Cordis 插件', 'tool.title.pwsh': 'Pwsh', + 'tool.title.readImage': '读取图片', 'tool.title.grep': 'Grep', 'tool.title.glob': 'Glob', 'tool.title.webSearch': '网页搜索', @@ -250,6 +251,7 @@ export const en = { 'tool.title.stopCordis': 'Stop Cordis Plugin', 'tool.title.removeCordis': 'Remove Cordis Plugin', 'tool.title.pwsh': 'Pwsh', + 'tool.title.readImage': 'Read image', 'tool.title.grep': 'Grep', 'tool.title.glob': 'Glob', 'tool.title.webSearch': 'Search', diff --git a/packages/client/ui-tool/README.i18n.yaml b/packages/client/ui-tool/README.i18n.yaml index 5cfd56493f..cbcf777c22 100644 --- a/packages/client/ui-tool/README.i18n.yaml +++ b/packages/client/ui-tool/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-tool/README.md -README.md: 773a93801ebc214e2d5c94d52864f5c5dd887100 -README.zh.md: 88df08d7b5b7d5d3978d90fd4df4cbbb2efeb1fa +README.md: 3461f6a90f791cccad968604b6cdd83535d15647 +README.zh.md: d2eb38b8382f56c77827540309237e876623df33 diff --git a/packages/client/ui-tool/README.md b/packages/client/ui-tool/README.md index 773a93801e..3461f6a90f 100644 --- a/packages/client/ui-tool/README.md +++ b/packages/client/ui-tool/README.md @@ -39,11 +39,11 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants keep the generic flattened form without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service. +The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `block`, optional `cwd` and `home`, the session-authorized `loadImage` loader (for a view whose result carries durable images), and plain `openFile`/`inspect` callbacks. A Code Dispatch block retains its event's `parentCallId`; a root Session call has no such field, so descendants keep the generic flattened form without another placement flag. Path summaries relativize to the Session cwd first, then replace a leftover POSIX Host home with `~`; `filePath` and Host open keep the authored filesystem path. The registration receives the normal Session slot runtime share but no React node or Runtime service. ### Built-in views -This package owns the generic fallback and the built-in shell/pwsh, read, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. +This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Foreground one-shot shell results use terminal cards. Settled persistent-shell results use the expandable generic input/output card because reset and partial-output diagnostics do not always describe one process exit status; background acknowledgements remain collapsed. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`. ----- @@ -61,7 +61,7 @@ The package realizes one dispatch rule: atomic Tool views are keyed by wire Tool ### Details and cards -The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes. +The package fills `conversation.details.tool` with `ToolDetails`. Row and Details renderers share one pure card model for each terminal, read, diff, search, and web card; the image card is row-only because its gallery renders through the tool-owned `tool.call.images` slot the details panel does not declare. These models validate raw call arguments, result content, failure state, persisted metadata, Code Dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. Card-specific limits and fallback rules remain in the owning [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md), [diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.md), [read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.md), [image](../../../.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.md), [search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.md), [web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.md), and [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.md) notes.
diff --git a/packages/client/ui-tool/README.zh.md b/packages/client/ui-tool/README.zh.md index 88df08d7b5..d2eb38b838 100644 --- a/packages/client/ui-tool/README.zh.md +++ b/packages/client/ui-tool/README.zh.md @@ -39,11 +39,11 @@ ctx.slots.inject('tool.call.toolview', () => }, BusinessToolRow)) ``` -owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`,以及普通的 `openFile`/`inspect` 回调。Code Dispatch block 保留事件的 `parentCallId`;root Session call 没有该字段,因此 descendant 无需另一项 placement 标志即可保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。 +owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`、可选 `cwd` 与 `home`、会话授权的 `loadImage` loader(供结果携带持久图像的视图使用),以及普通的 `openFile`/`inspect` 回调。Code Dispatch block 保留事件的 `parentCallId`;root Session call 没有该字段,因此 descendant 无需另一项 placement 标志即可保持 generic 压平形态。路径摘要先相对 Session cwd 缩短,再把剩余的 POSIX Host home 写成 `~`;`filePath` 与 Host 打开仍使用作者给出的文件系统路径。注册项会收到常规 Session slot runtime share,但不会收到 React node 或 runtime service。 ### 内置视图 -本包拥有 generic fallback,以及 shell/pwsh、read、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 +本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、running `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。前台一次性 shell 结果使用 terminal 卡片。已完成的持久 shell 结果使用可展开的 generic 输入/输出卡片,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;后台启动回执保持折叠。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。 ----- @@ -61,7 +61,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block` ### 详情与卡片 -本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。 +本包通过 `ToolDetails` 填充 `conversation.details.tool`。行 renderer 与 Details renderer 分别为 terminal、read、diff、search 和 web 卡片复用同一个纯 card model;image 卡片仅属于行,因为其图库经由工具自有 `tool.call.images` 槽位渲染,而 details 面板不声明该槽位。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、Code Dispatch `parentCallId` 与 Session 路径事实。不受支持或格式错误的输入使用压平的工具结果文本。各类卡片的上限与 fallback 规则仍由对应的 [terminal](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.zh.md)、[diff](../../../.agents/notes/implemented/feature/2026-07-30-web-diff-card.zh.md)、[read](../../../.agents/notes/implemented/feature/2026-07-30-web-read-card-frontend.zh.md)、[image](../../../.agents/notes/implemented/feature/2026-08-20-tool-card-image-results.zh.md)、[search](../../../.agents/notes/implemented/feature/2026-07-30-web-search-card.zh.md)、[web](../../../.agents/notes/implemented/feature/2026-07-30-web-result-card-frontend.zh.md) 与 [question](../../../.agents/notes/implemented/feature/2026-07-29-ask-question-web-presentation.zh.md) 笔记负责。
diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 3a26243df1..129ecd9cd3 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -69,7 +69,8 @@ "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index 1324e8cbed..7c153dc515 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -13,6 +13,7 @@ import { askQuestionToolview } from './tool/toolviews/ask-question-row.tsx' import { bashToolviewSample } from './tool/toolviews/bash-sample.tsx' import { fileMutationToolview } from './tool/toolviews/file-mutation-row.tsx' import { readToolview } from './tool/toolviews/read-row.tsx' +import { readImageToolview } from './tool/toolviews/read-image-row.tsx' import { searchToolview } from './tool/toolviews/search-row.tsx' import { todoToolview } from './tool/toolviews/todo-row.tsx' import { webToolview } from './tool/toolviews/web-row.tsx' @@ -48,6 +49,7 @@ export function apply(ctx: ClientContext): void { ctx.plugin(bashToolviewSample) ctx.plugin(readToolview) + ctx.plugin(readImageToolview) ctx.plugin(fileMutationToolview) ctx.plugin(searchToolview) ctx.plugin(webToolview) diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index 703e102ac4..d99a64a626 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -4,7 +4,7 @@ import type { } from '@deepseek-ai/dsh-client-ui-slots' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { MessageImageLoader, MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' declare module '@deepseek-ai/dsh-client-ui-slots' { @@ -24,9 +24,30 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * function of what the turn already knows. */ 'tool.call.toolview': { kind: 'keyed'; scope: 'session'; owner: ToolCallOwnerProps } + /** + * Durable images of a settled image-bearing Tool call, rendered through + * the attachment presentation plugin. The Tool layer never imports an + * attachment implementation: a toolview declares this slot as a child and + * renders it with the image card's references plus the session-authorized + * loader it received in its owner, and the attachment plugin fills the + * gallery. Composing no attachment presentation plugin renders nothing, + * which is why the image card keeps its own envelope text beside the + * gallery. + */ + 'tool.call.images': { kind: 'single'; scope: 'session'; owner: ToolImagesOwnerProps } } } +/** Owner currency of the Tool image gallery slot: references plus the loader. */ +export interface ToolImagesOwnerProps { + /** Durable references or submission-echo previews in result order. */ + images: readonly MessageImageSource[] + /** Session-authorized image URL loader for the durable arm. */ + loadImage: MessageImageLoader + /** Horizontal placement inside the owning record. */ + align: 'start' | 'end' +} + /** Standard owner currency supplied to every atomic Tool view. */ export interface ToolCallOwnerProps { /** Tool call identity, stable across running and settled forms. */ @@ -41,6 +62,14 @@ export interface ToolCallOwnerProps { home?: string | undefined /** Open a Tool argument path through the Host. */ openFile: (path: string) => void + /** + * Session-authorized image loader for the `tool.call.images` slot, supplied + * by the chat node that owns this call. A composed chat node always + * supplies it (`ChatNodeOwnerProps.loadImage` is required), so the tool + * layer never imports an attachment implementation nor handles URL + * authorization. + */ + loadImage: MessageImageLoader /** Inspect this call in the trajectory view when available. */ inspect?: (() => void) | undefined } diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 2a11948077..d8288fa71a 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -12,8 +12,8 @@ function callName(node: ToolCallBlock): string { /** One atomic call dispatched through the Tool-owned keyed slot. */ const ToolCall = memo(function ToolCall({ - renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, t, children, -}: Pick & { + renderSlot, callId, toolName, block, openFile, selected, cwd, home, inspectCall, loadImage, t, children, +}: Pick & { callId: string toolName: string block: ToolCallBlock @@ -28,8 +28,9 @@ const ToolCall = memo(function ToolCall({ openFile, cwd, home, + loadImage, inspect: () => { inspectCall(callId) }, - }), [callId, toolName, block, openFile, cwd, home, inspectCall]) + }), [callId, toolName, block, openFile, cwd, home, loadImage, inspectCall]) return (
& { + renderSlot, block, selectedCallId, cwd, home, openFile, inspectCall, loadImage, t, +}: Pick & { block: ToolCallBlock home?: string | undefined }) { @@ -63,6 +64,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ cwd={cwd} home={home} inspectCall={inspectCall} + loadImage={loadImage} t={t} > {block.subCalls.length > 0 ? ( @@ -77,6 +79,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ home={home} openFile={openFile} inspectCall={inspectCall} + loadImage={loadImage} t={t} /> ))} @@ -93,7 +96,7 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostInfo, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, loadImage, useHostInfo, t, }: ToolTreeProps) { const home = useHostInfo(info => info.home) const block = node.data.root @@ -106,6 +109,7 @@ export function ToolCallTree({ home={home} openFile={openFile} inspectCall={inspectCall} + loadImage={loadImage} t={t} /> ) diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css index 2f344ac0dd..da78d23872 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.module.css @@ -288,6 +288,7 @@ .terminalBody, .diffBody, .readBody, +.imageBody, .searchBody, .webBody { margin: 4px 0 4px 4px; @@ -304,6 +305,28 @@ color: var(--dsw-alias-label-tertiary); } +/* The image card's in-card label: the view's replacement title, else the + shortened path — the same role ReadBlock's own label plays, so the presentation + contract's replacement-title rule is honoured here too. */ +.imageLabel { + margin-bottom: 4px; + overflow-wrap: anywhere; + font: var(--dsw-font-sm-13); + color: var(--dsw-alias-label-secondary); +} + +/* The image card's own result text (media type, dimensions, byte size) under the + gallery, in the same muted tone the search recovery footer uses. Always + rendered: the attachment presentation slot is optional, so when nothing + occupies it the gallery is empty and this line is the only evidence an image + was returned. The enclosing .imageBody already carries the row indent. */ +.imageMeta { + white-space: pre-wrap; + overflow-wrap: anywhere; + font: var(--dsw-font-xs-13); + color: var(--dsw-alias-label-tertiary); +} + /* In-row code renders at the smaller code size (12/18) via each primitive's rebindable content-font seam; standalone markdown code blocks keep 13/22. */ .codeBody { diff --git a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx index ab38bc9dbc..f3517130cb 100644 --- a/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx +++ b/packages/client/ui-tool/src/client/tool/components/ToolRow.tsx @@ -4,9 +4,11 @@ import { CodeBlock, DiffBlock, DisclosureRow, IconInspectOutline12, ReadBlock, SearchBlock, StateDot, TerminalBlock, WebBlock, diffTotals, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { MessageImageLoader } from '@deepseek-ai/dsh-client-ui-conversation/client' import { CHAT_DIFF_MAX_LINES, type DiffCardModel } from '../models/diff-card-model.ts' import { CHAT_READ_MAX_LINES, type ReadCardModel } from '../models/read-card-model.ts' +import type { ImageCardModel } from '../models/image-card-model.ts' import { CHAT_SEARCH_MAX_LINES, type SearchCardModel } from '../models/search-card-model.ts' import { localizeTerminalCardModel, terminalBlockLabels, type TerminalCardModel, @@ -48,6 +50,21 @@ export interface ToolRowProps { terminal?: TerminalCardModel | null | undefined diff?: DiffCardModel | null | undefined read?: ReadCardModel | null | undefined + /** + * Image-card material for a call whose result is an image (derived by + * `imageCardModel`). Rendered through the `tool.call.images` slot, so the + * tool layer never imports an attachment implementation nor handles URL + * authorization. + */ + image?: ImageCardModel | null | undefined + /** + * Dispatch the image gallery through the tool-owned `tool.call.images` + * slot, supplied by the toolview that owns this row together with the + * session-authorized loader. + */ + renderSlot?: PropsRenderSlots<'tool.call.images'>['renderSlot'] | undefined + /** Session-authorized image URL loader for the gallery slot. */ + loadImage?: MessageImageLoader | undefined search?: SearchCardModel | null | undefined web?: WebCardModelProps | null | undefined state: ToolRowState @@ -101,6 +118,9 @@ export function ToolRow({ terminal, diff, read, + image, + renderSlot, + loadImage, search, web, state, @@ -119,11 +139,14 @@ export function ToolRow({ : localizeTerminalCardModel(terminal, t) const diffBody = diff ?? null const readBody = read ?? null + const imageBody = image !== undefined && image !== null && renderSlot !== undefined && loadImage !== undefined + ? image + : null const searchBody = search ?? null const webBody = web ?? null const askQuestionBody = askQuestion ?? null const outputText = output ?? null - const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? searchBody ?? webBody + const card = askQuestionBody ?? terminalBody ?? diffBody ?? readBody ?? imageBody ?? searchBody ?? webBody const expandable = body !== null || outputText !== null || card !== null const open = expanded && expandable const status = stateStatus(state, t) @@ -215,54 +238,74 @@ export function ToolRow({ ? : readBody !== null ? - : searchBody !== null + : imageBody !== null ? ( - <> - - {/* A capped search's recovery locator lives only in the result - text; show it below the card so the dropped rows survive. */} - {searchBody.recovery !== undefined && ( -
{searchBody.recovery}
- )} - + /* Label, gallery, then the result's OWN envelope text. The text + comes from the image card model (which reads the result's text + block), never from the row's flattened output: an image read's + content is [text envelope, image block] and flattening + JSON.stringifies the image block, printing the raw attachment + object under the picture. It is not redundant either — the + attachment slot can render nothing, and then this line is the + only evidence an image was returned. */ +
+
{imageBody.label}
+ {renderSlot !== undefined && loadImage !== undefined && renderSlot('tool.call.images', { + images: imageBody.images, + loadImage, + align: 'start', + })} +
{imageBody.text}
+
) - : webBody !== null - ? - : ( + : searchBody !== null + ? ( <> - {variant === 'code' && body !== null && ( -
- -
- )} - {(cardBody !== null || outputText !== null) && ( -
- {cardBody !== null && ( -
- {t('row.input')} - {cardBody} -
- )} - {cardBody !== null && outputText !== null && ( - - )} - {outputText !== null && ( -
- {t('row.output')} - - {outputText} - -
- )} -
+ + {/* A capped search's recovery locator lives only in the result + text; show it below the card so the dropped rows survive. */} + {searchBody.recovery !== undefined && ( +
{searchBody.recovery}
)} - )} + ) + : webBody !== null + ? + : ( + <> + {variant === 'code' && body !== null && ( +
+ +
+ )} + {(cardBody !== null || outputText !== null) && ( +
+ {cardBody !== null && ( +
+ {t('row.input')} + {cardBody} +
+ )} + {cardBody !== null && outputText !== null && ( + + )} + {outputText !== null && ( +
+ {t('row.output')} + + {outputText} + +
+ )} +
+ )} + + )} {inspect !== undefined && (