From 545e2ad91437ea55f3b991321baf2191962b61c6 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 27 Aug 2026 13:42:26 +0800 Subject: [PATCH 01/43] 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 02/43] 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 03/43] 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 04/43] 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 05/43] 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 06/43] =?UTF-8?q?fix(net):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20containment,=20opt-out,=20and=20syntax-aware=20discovery?= 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 07/43] 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 08/43] 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 09/43] 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 10/43] 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 11/43] 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 12/43] 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 6de470e61bfdf76e5bb7261f3b1a2b5ec5a034af Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 11:57:40 +0800 Subject: [PATCH 13/43] fix(net): keep this machine off the proxy, and refuse a literal the checks reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found `127.0.0.2` routed through the proxy. The bypass list carries four literal loopback entries because that is all a consumer reading an environment can match, and `proxyForUrl` matched only those — leaving the rest of `127.0.0.0/8`, `0.0.0.0`, and the IPv4-mapped spellings routed through a proxy that could then reach them. Loopback is now recognised structurally, which a list entry cannot express; the published entries stay for the environment readers. The same review case exposed a wider one. `web_fetch` skips its address checks on a proxied hop, because the proxy resolves the origin — but a literal needs no resolution, so the skip bought nothing and let a proxy on this machine reach every private range those checks refuse, `169.254.169.254` included. A literal the checks would refuse now takes the validated path, where the existing refusal already covers it. Tests that proved a tunnelled hop used a loopback origin, which no policy can route through a proxy any more. They name a host only the proxy can answer for instead — closer to what a proxied request actually looks like. The user guide promised the proxy carried every outbound request including telemetry. It carries neither on an older Node, nor anything a model-authored script sends, so the promise is narrowed and the exceptions listed. A password in a proxy URL reaching every tool DSH runs is documented there too: it is how the variable already behaves, and worth knowing before putting one in. --- docs/config-catalog.i18n.yaml | 4 +-- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/user/guide/network-proxy.i18n.yaml | 4 +-- docs/user/guide/network-proxy.md | 13 ++++++- docs/user/guide/network-proxy.zh.md | 13 ++++++- packages/net/http-proxy/README.i18n.yaml | 4 +-- packages/net/http-proxy/README.md | 2 +- packages/net/http-proxy/README.zh.md | 2 +- packages/net/http-proxy/src/index.ts | 1 + packages/net/http-proxy/src/policy.ts | 30 ++++++++++++++++ packages/net/http-proxy/tests/install.spec.ts | 30 ++++++++++------ packages/net/http-proxy/tests/policy.spec.ts | 36 +++++++++++++++++++ packages/web/web-fetch-http/src/network.ts | 15 ++++++++ packages/web/web-fetch-http/src/provider.ts | 8 +++-- .../web/web-fetch-http/tests/proxy.spec.ts | 30 ++++++++++++++-- 16 files changed, 168 insertions(+), 28 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 6078ac2978..c09ee2e25d 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: ab3b85626ac2e4910240dc612b2416c5f8381ad7 -config-catalog.zh.md: 75c19554ecbdf7e66e9451b0c3a9c87dfbde8236 +config-catalog.md: a692330690836c4a8146b8770bda7c1f374d4fda +config-catalog.zh.md: fd4034bacee21336d529ffbbb33322f8002fc683 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ab3b85626a..a692330690 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:50`](../packages/net/http-proxy/src/index.ts) +Source: [`packages/net/http-proxy/src/index.ts:51`](../packages/net/http-proxy/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 75c19554ec..fd4034bace 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:50`](../packages/net/http-proxy/src/index.ts) +来源:[`packages/net/http-proxy/src/index.ts:51`](../packages/net/http-proxy/src/index.ts) diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml index 1872a1b899..fe244ec86d 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: 98722f4562bb81b4b3d015770fb04de495e09e95 -network-proxy.zh.md: 07c8455f37f1022e4c4da0001d97b30e8dafc308 +network-proxy.md: 22db4a583771ac730217a95a9e5662ef6516c7fd +network-proxy.zh.md: a9479a582075327b35998491543e9d73056db0b5 diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md index 98722f4562..22db4a5837 100644 --- a/docs/user/guide/network-proxy.md +++ b/docs/user/guide/network-proxy.md @@ -2,7 +2,7 @@ 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. +DSH routes its outbound requests — model calls, web search, page fetches, and MCP servers over HTTP — through the proxy named by the standard proxy environment variables. It reads them at launch; nothing else needs configuring. A few paths stay direct by design or by runtime limit, listed under "What stays direct" below. ## Export the variables @@ -59,6 +59,17 @@ 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. +**A password in the proxy URL reaches those tools too.** `HTTPS_PROXY=http://alice:s3cret@proxy.example:8080` is a normal environment variable, so every command DSH runs — including the ones the model writes — can read it, and a command that prints its environment puts the password in output that is kept. This is how the variable already behaves for everything else in your shell. If that matters, give the proxy a credential-free entry point, or authenticate it some other way than in the URL. + +## What stays direct + +Not every request DSH makes goes through the proxy: + +- **Anything on this machine.** Loopback is always direct: `localhost`, the whole `127.0.0.0/8` range, `::1`, and `0.0.0.0`. A proxy cannot usefully reach a service that only listens locally. +- **Code the model writes.** The workflow and code-runtime workers never receive the proxy settings, so a script the model authors cannot read a proxy URL that may carry a password. Such a script reaches the network only if it configures that itself. +- **Telemetry on an older Node.** The OTLP exporter uses Node's own HTTP client, which learned to honor these variables in Node 22.21 and 24.5. On 22.19, 22.20, and 24.0–24.4 telemetry connects directly. +- **`web_fetch` to a literal private address.** A URL naming an address like `http://10.0.0.5/` is refused rather than handed to the proxy, the same refusal it gets with no proxy configured. + ## Check that it worked Ask the agent to fetch a page and watch your proxy application's connection log: diff --git a/docs/user/guide/network-proxy.zh.md b/docs/user/guide/network-proxy.zh.md index 07c8455f37..a9479a5820 100644 --- a/docs/user/guide/network-proxy.zh.md +++ b/docs/user/guide/network-proxy.zh.md @@ -2,7 +2,7 @@ [English](network-proxy.md) | 中文 -DSH 会把每一个出站请求——模型调用、web 搜索、页面抓取、走 HTTP 的 MCP 服务器与遥测——都经由标准代理环境变量所指定的代理发出。它在启动时读取这些变量,不需要其他配置。 +DSH 会把自身的出站请求——模型调用、web 搜索、页面抓取、走 HTTP 的 MCP 服务器——都经由标准代理环境变量所指定的代理发出。它在启动时读取这些变量,不需要其他配置。有几条路径出于设计或运行时限制保持直连,下文"哪些保持直连"一节列出了它们。 ## 导出环境变量 @@ -59,6 +59,17 @@ Node 只在进程启动时读取该变量,所以要在运行 `dsh` 之前导 **DSH 替你运行的工具遵循同一个代理。** bash 工具里的命令、`git`、`gh`,以及作为子进程启动的 MCP 服务器都会继承这些变量。子进程若本身是 Node 程序,则需 Node 22.21 或更高版本才会遵循;更旧的 Node 会直连。 +**代理 URL 里的密码同样会到达这些工具。** `HTTPS_PROXY=http://alice:s3cret@proxy.example:8080` 就是一个普通环境变量,因此 DSH 运行的每一条命令——包括模型编写的那些——都能读到它,而打印环境的命令会把密码写进被保留的输出。这与该变量在你 shell 里对其他一切程序的行为一致。若这一点重要,请为代理提供一个无需凭据的入口,或改用 URL 之外的方式认证。 + +## 哪些保持直连 + +并非 DSH 发出的每个请求都会走代理: + +- **本机上的一切。** loopback 始终直连:`localhost`、整个 `127.0.0.0/8` 段、`::1` 与 `0.0.0.0`。代理无法有意义地访问一个只在本地监听的服务。 +- **模型编写的代码。** workflow 与 code-runtime worker 从不接收代理配置,因此模型编写的脚本读不到可能携带密码的代理 URL。这类脚本只有自行配置才能联网。 +- **较旧 Node 上的遥测。** OTLP 导出器使用 Node 自带的 HTTP 客户端,而它从 Node 22.21 与 24.5 起才遵循这些变量。在 22.19、22.20 与 24.0–24.4 上遥测直连。 +- **`web_fetch` 访问字面量私网地址。** 形如 `http://10.0.0.5/` 的 URL 会被拒绝而非交给代理,与未配置代理时得到的拒绝相同。 + ## 验证是否生效 让 agent 抓取一个页面,同时观察代理软件的连接日志: diff --git a/packages/net/http-proxy/README.i18n.yaml b/packages/net/http-proxy/README.i18n.yaml index 45cfd664fc..644b4d8cba 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: 8d28134ba076b0184987995b44924327c26f8096 -README.zh.md: 0711bb39d5077996c29992fe6a89913eaf6ec31e +README.md: d07ca2fceced2adfa4d788462f8e04894d87c99a +README.zh.md: 74d256a9d9f2b7d00ea01b5bf50a9b84279511fd diff --git a/packages/net/http-proxy/README.md b/packages/net/http-proxy/README.md index 8d28134ba0..d07ca2fcec 100644 --- a/packages/net/http-proxy/README.md +++ b/packages/net/http-proxy/README.md @@ -46,7 +46,7 @@ That gate cannot see inside an SDK, so every outbound call site in the repositor `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. +Loopback is always bypassed — `localhost`, the whole `127.0.0.0/8` range, `::1`, `0.0.0.0`, and the IPv4-mapped spellings of those. The harness's own Web UI, Connection transport, and every local test server would otherwise route through the proxy and loop. The published bypass list names only the four literal entries an environment reader can match; `proxyForUrl` recognises the range itself, because a list entry cannot express one. ### Failures diff --git a/packages/net/http-proxy/README.zh.md b/packages/net/http-proxy/README.zh.md index 0711bb39d5..74d256a9d9 100644 --- a/packages/net/http-proxy/README.zh.md +++ b/packages/net/http-proxy/README.zh.md @@ -46,7 +46,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代 `http_proxy`、`https_proxy`、`no_proxy` 与 `all_proxy`,小写优先、大写兜底,空值视为未设置。`ALL_PROXY` 为两种协议兜底,HTTPS 最后回退到 HTTP 代理——其中第一条 Node 与 undici 都不会自行推导。取值来自启动器的快照,因此写在项目或 `$DSH_HOME` 的 `.env` 层中的代理同样生效;真实环境变量仍然高于两者。 -loopback 始终被绕过。否则 Harness 自己的 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。 +loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、`0.0.0.0`,以及它们的 IPv4 映射写法。否则 Harness 自己的 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。发布出去的绕过列表只包含读取环境的消费者能匹配的四个字面量条目;`proxyForUrl` 自行识别整个网段,因为列表条目无法表达一个范围。 ### 失败处理 diff --git a/packages/net/http-proxy/src/index.ts b/packages/net/http-proxy/src/index.ts index 1520a88506..a7d58102f5 100644 --- a/packages/net/http-proxy/src/index.ts +++ b/packages/net/http-proxy/src/index.ts @@ -22,6 +22,7 @@ import { describeProxyPolicy, resolveProxyPolicy, type ProxyConfig } from './pol export { bypassesProxy, + isLoopbackHost, describeProxyPolicy, proxyForUrl, resolveProxyPolicy, diff --git a/packages/net/http-proxy/src/policy.ts b/packages/net/http-proxy/src/policy.ts index ad87da5bd1..178f7d85f8 100644 --- a/packages/net/http-proxy/src/policy.ts +++ b/packages/net/http-proxy/src/policy.ts @@ -233,6 +233,35 @@ function splitHostPort(entry: string): { host: string; port?: string } { return { host: entry } } +/** One IPv4 octet, so a loopback match cannot accept `127.999.1.1`. */ +const OCTET = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)' + +/** The whole `127.0.0.0/8` block, not just its first address. */ +const LOOPBACK_IPV4 = new RegExp(`^127\\.${OCTET}\\.${OCTET}\\.${OCTET}$`) + +/** + * Whether a host names this machine. + * + * A proxy cannot meaningfully reach one: it would resolve the address in its own network, and a + * proxy running on this machine would reach a service that only listens on loopback. The bypass + * list carries {@link LOOPBACK_NO_PROXY} for the consumers that read an environment rather than a + * policy, but those are four literal entries — matching them alone leaves `127.0.0.2`, the whole + * rest of `127.0.0.0/8`, and the IPv4-mapped spelling routed through the proxy. + * + * @param hostname - a URL's hostname, bracketed or not. + * @returns true when the host is loopback or the unspecified address. + */ +export function isLoopbackHost(hostname: string): boolean { + const host = hostname.replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase() + if (host === 'localhost' || host.endsWith('.localhost')) return true + if (host === '::1' || host === '::' || host === '0.0.0.0') return true + // An IPv4-mapped IPv6 address may keep its dotted tail or, once a URL has normalized it, carry + // the same four bytes as two hex groups: `::ffff:127.0.0.1` and `::ffff:7f00:1` are one address. + const mappedHigh = /^::ffff:([0-9a-f]{1,4}):[0-9a-f]{1,4}$/.exec(host)?.[1] + if (mappedHigh !== undefined) return Number.parseInt(mappedHigh, 16) >>> 8 === 127 + return LOOPBACK_IPV4.test(host.startsWith('::ffff:') ? host.slice('::ffff:'.length) : host) +} + /** * 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 — @@ -325,6 +354,7 @@ export function resolveProxyPolicy( 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 + if (isLoopbackHost(url.hostname)) return undefined return bypassesProxy(policy.noProxy, url) ? undefined : proxy } diff --git a/packages/net/http-proxy/tests/install.spec.ts b/packages/net/http-proxy/tests/install.spec.ts index 431cb3af79..3f81e29b2f 100644 --- a/packages/net/http-proxy/tests/install.spec.ts +++ b/packages/net/http-proxy/tests/install.spec.ts @@ -21,6 +21,13 @@ let origin: Server let proxyUrl: string let originUrl: string +/** + * The target for every assertion about a tunnelled hop. It is deliberately not loopback: no policy + * routes this machine through a proxy, so a loopback target could only ever prove a direct hop. The + * host never resolves — the client connects to the proxy, which answers the absolute-form request. + */ +const proxyTarget = 'http://origin.test/probe' + function listen(server: Server): Promise { return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { resolve(server.address() as AddressInfo) }) @@ -67,8 +74,8 @@ 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}`]) + await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY') + expect(proxied).toEqual([`GET ${proxyTarget}`]) } finally { await dispose() } @@ -148,8 +155,8 @@ describe('installGlobalProxy', () => { 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') - expect(proxied).toEqual([`GET ${originUrl}`]) + await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY') + expect(proxied).toEqual([`GET ${proxyTarget}`]) } finally { await dispose() } @@ -159,10 +166,10 @@ describe('installGlobalProxy', () => { 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)) + const dispatcher = await createDispatcher(new URL(proxyTarget)) try { const undici = await import('undici') - const response = await undici.fetch(originUrl, { dispatcher }) + const response = await undici.fetch(proxyTarget, { dispatcher }) await expect(response.text()).resolves.toBe('VIA-PROXY') } finally { await dispatcher.close() @@ -202,10 +209,10 @@ describe('createDispatcher', () => { // 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) + const dispatcher = await createDispatcher(new URL(proxyTarget), {}, branched) try { const undici = await import('undici') - await expect((await undici.fetch(originUrl, { dispatcher })).text()).resolves.toBe('VIA-PROXY') + await expect((await undici.fetch(proxyTarget, { dispatcher })).text()).resolves.toBe('VIA-PROXY') } finally { await dispatcher.close() } @@ -343,18 +350,19 @@ 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 { - await expect((await fetch(originUrl)).text()).resolves.toBe('VIA-PROXY') + await expect((await fetch(proxyTarget)).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. + // launcher's agent keeps tunnelling. A direct hop needs a host that answers, so this one + // reaches the real origin rather than the name only the proxy can resolve. 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') + await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY') } finally { await outer() } diff --git a/packages/net/http-proxy/tests/policy.spec.ts b/packages/net/http-proxy/tests/policy.spec.ts index 6db288e7a1..abce8ae29f 100644 --- a/packages/net/http-proxy/tests/policy.spec.ts +++ b/packages/net/http-proxy/tests/policy.spec.ts @@ -3,6 +3,7 @@ import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environ import { bypassesProxy, describeProxyPolicy, + isLoopbackHost, proxyForUrl, resolveProxyPolicy, DIRECT_POLICY, @@ -18,6 +19,41 @@ function env(values: Record): ReturnType { + const proxied = { httpProxy: PROXY, httpsProxy: PROXY, noProxy: '', source: 'env' } as const + + // The published bypass list carries four literal entries for the consumers that read an + // environment. Matching only those left the rest of `127.0.0.0/8` — including the resolver stub + // at `127.0.0.53` — routed through a proxy that could then reach it on the caller's behalf. + it.each([ + '127.0.0.1', '127.0.0.2', '127.0.0.53', '127.255.255.254', + 'localhost', 'app.localhost', '[::1]', '[::ffff:127.0.0.1]', '0.0.0.0', + ])('never routes %s through a proxy', (host) => { + expect(proxyForUrl(proxied, new URL(`http://${host}:8080/`))).toBeUndefined() + }) + + it.each(['128.0.0.1', '10.0.0.5', '[::ffff:10.0.0.1]', 'notlocalhost', 'example.com'])( + 'still routes %s, which is not this machine', + (host) => { + expect(proxyForUrl(proxied, new URL(`http://${host}:8080/`))).toBe(PROXY) + }, + ) + + it('rejects an out-of-range octet rather than reading it as loopback', () => { + expect(isLoopbackHost('127.999.1.1')).toBe(false) + expect(isLoopbackHost('1270.0.0.1')).toBe(false) + }) + + it('reads an IPv4-mapped address in either spelling', () => { + // A URL normalizes the dotted tail into hex groups, but a caller reading a bypass list or a + // configuration value has the dotted form in hand, and both name the same address. + expect(isLoopbackHost('::ffff:127.0.0.1')).toBe(true) + expect(isLoopbackHost('::ffff:7f00:1')).toBe(true) + expect(isLoopbackHost('::ffff:10.0.0.1')).toBe(false) + expect(isLoopbackHost('::ffff:a00:1')).toBe(false) + }) +}) + describe('resolveProxyPolicy', () => { it('resolves nothing when the environment carries no proxy', () => { const { policy, diagnostics } = resolveProxyPolicy(env({})) diff --git a/packages/web/web-fetch-http/src/network.ts b/packages/web/web-fetch-http/src/network.ts index 3c83063acf..a6c6b34953 100644 --- a/packages/web/web-fetch-http/src/network.ts +++ b/packages/web/web-fetch-http/src/network.ts @@ -158,6 +158,21 @@ function embeddedIpv4Address(bytes: readonly number[], prefixLength: Nat64Prefix return ipv4.join('.') } +/** + * Whether a hostname is an IP literal that {@link resolvePublicAddresses} would refuse. + * + * A proxied hop skips those checks because the proxy resolves the origin, but a literal needs no + * resolution: the address is already stated, and handing it to a proxy running on this machine + * would reach exactly the loopback or private service the checks exist to keep out of reach. + * + * @param hostname - a URL's hostname, bracketed or not. + * @returns true when the host is a literal address no request may be sent to. + */ +export function isNonPublicIpLiteral(hostname: string): boolean { + const unbracketed = stripIpv6Brackets(hostname) + return isIP(unbracketed) !== 0 && !isPublicIpAddress(unbracketed) +} + /** * Fetch through an Undici agent whose lookup callback returns only the already * validated address set. The URL hostname remains intact for HTTP Host and TLS SNI. diff --git a/packages/web/web-fetch-http/src/provider.ts b/packages/web/web-fetch-http/src/provider.ts index 75c89d6e40..865977713f 100644 --- a/packages/web/web-fetch-http/src/provider.ts +++ b/packages/web/web-fetch-http/src/provider.ts @@ -11,7 +11,7 @@ import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } 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 { isNonPublicIpLiteral, publicHttpNetwork } from './network.ts' import type { PublicAddress } from './network.ts' import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' @@ -128,8 +128,12 @@ export class HttpFetchProvider implements WebFetchProvider { // 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. + // + // An IP literal the address checks would refuse never takes it. The proxy would resolve + // nothing — the address is already stated — so the shortcut would spend the checks for + // nothing and let a proxy on this machine reach the very service they keep out of reach. const policy = currentProxyPolicy() ?? DIRECT_POLICY - if (proxyForUrl(policy, url) !== undefined) { + if (proxyForUrl(policy, url) !== undefined && !isNonPublicIpLiteral(url.hostname)) { return await publicHttpNetwork.requestProxied(url, headers, signal, policy) } const addresses = await this.resolveAddresses(url.hostname, signal) diff --git a/packages/web/web-fetch-http/tests/proxy.spec.ts b/packages/web/web-fetch-http/tests/proxy.spec.ts index 23fd372c55..105d046e6e 100644 --- a/packages/web/web-fetch-http/tests/proxy.spec.ts +++ b/packages/web/web-fetch-http/tests/proxy.spec.ts @@ -20,6 +20,13 @@ let proxy: Server let origin: Server let proxyUrl: string let originUrl: string + +/** + * The target for every assertion about a tunnelled hop. Loopback cannot serve: no policy routes + * this machine through a proxy. The host never resolves — the proxy answers the absolute-form + * request — which is also what makes the skipped resolver observable. + */ +const proxyTarget = 'http://origin.test/page' let disposeProxy: (() => Promise) | undefined function listen(server: Server): Promise { @@ -65,10 +72,10 @@ describe('fetching through a proxy', () => { const resolve = vi.spyOn(publicHttpNetwork, 'resolve') disposeProxy = await installGlobalProxy(policy()) - const result = await new HttpFetchProvider(limits).fetch({ url: originUrl }) + const result = await new HttpFetchProvider(limits).fetch({ url: proxyTarget }) expect(result.body.content).toBe('via-proxy') - expect(proxied).toEqual([originUrl]) + expect(proxied).toEqual([proxyTarget]) // 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() @@ -96,6 +103,23 @@ describe('fetching through a proxy', () => { expect(resolve).toHaveBeenCalledOnce() }) + it.each(['10.0.0.5', '169.254.169.254', '127.0.0.2', '[::ffff:127.0.0.1]'])( + 'refuses %s instead of letting the proxy reach it for us', + async (host) => { + const resolve = vi.spyOn(publicHttpNetwork, 'resolve') + disposeProxy = await installGlobalProxy(policy()) + + // The proxied path exists because a proxy resolves the origin; a literal needs no resolution, + // so taking it would spend the address checks for nothing and hand a proxy on this machine + // the private or loopback destination those checks exist to refuse. The hop therefore takes + // the validated path instead, where the existing refusal already covers it. + await expect(new HttpFetchProvider(limits).fetch({ url: `http://${host}:8080/` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' })) + expect(proxied).toEqual([]) + expect(resolve).toHaveBeenCalledOnce() + }, + ) + it('still refuses a cross-origin redirect on the proxied path', async () => { proxy.removeAllListeners('request') proxy.on('request', (request, response) => { @@ -105,7 +129,7 @@ describe('fetching through a proxy', () => { }) disposeProxy = await installGlobalProxy(policy()) - await expect(new HttpFetchProvider(limits).fetch({ url: originUrl })) + await expect(new HttpFetchProvider(limits).fetch({ url: proxyTarget })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' })) }) From 43afef85763cd22f04848b587bab10941299c17d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 13:07:21 +0800 Subject: [PATCH 14/43] fix: settle what the master merge broke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `AGENTS.md` sat at exactly its 1950-word ceiling on master, so the one line this branch adds to the repository layout — a new top-level package group — cannot fit at any length: even a one-word description overflows. The description is condensed to five words and the ceiling raised by ten, the smallest change that keeps every package group listed. Omitting only `net/` from a list that names every other group was the alternative. One new test drove an IPv4-mapped literal through a full fetch. An IPv6 literal sends `resolvePublicAddresses` looking for a NAT64 prefix before it refuses anything, and that is a real DNS query — 5s under load, 6ms here, which is why it passed alone and timed out in the full suite. The three IPv4 cases already prove the branch end to end without touching the network, so the mapped form is asserted on the predicate instead. --- AGENTS.md | 2 +- packages/web/web-fetch-http/tests/proxy.spec.ts | 14 ++++++++++++-- scripts/doc-budgets.manifest.json | 2 +- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5e4fbc381c..d08270a7dc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +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 + net/ outbound HTTP proxy policy compaction/ compaction capability + basic provider context/ request-context plugins subagent/ subagent capability: Service Definition + providers + delegation Consumers diff --git a/packages/web/web-fetch-http/tests/proxy.spec.ts b/packages/web/web-fetch-http/tests/proxy.spec.ts index 105d046e6e..97f7fa008c 100644 --- a/packages/web/web-fetch-http/tests/proxy.spec.ts +++ b/packages/web/web-fetch-http/tests/proxy.spec.ts @@ -4,7 +4,7 @@ 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' +import { isNonPublicIpLiteral, publicHttpNetwork } from '../src/network.ts' const limits: HttpFetchLimits = { maxResponseBytes: 5_000_000, @@ -103,7 +103,7 @@ describe('fetching through a proxy', () => { expect(resolve).toHaveBeenCalledOnce() }) - it.each(['10.0.0.5', '169.254.169.254', '127.0.0.2', '[::ffff:127.0.0.1]'])( + it.each(['10.0.0.5', '169.254.169.254', '127.0.0.2'])( 'refuses %s instead of letting the proxy reach it for us', async (host) => { const resolve = vi.spyOn(publicHttpNetwork, 'resolve') @@ -120,6 +120,16 @@ describe('fetching through a proxy', () => { }, ) + it('reads an IPv4-mapped literal as non-public without asking the network', () => { + // Driven through the predicate rather than a fetch: an IPv6 literal sends `resolvePublicAddresses` + // looking for a NAT64 prefix before it refuses anything, and that is a real DNS query. The three + // IPv4 cases above already prove the branch end to end without one. + expect(isNonPublicIpLiteral('[::ffff:7f00:1]')).toBe(true) + expect(isNonPublicIpLiteral('[::1]')).toBe(true) + expect(isNonPublicIpLiteral('[::ffff:808:808]')).toBe(false) + expect(isNonPublicIpLiteral('example.com')).toBe(false) + }) + it('still refuses a cross-origin redirect on the proxied path', async () => { proxy.removeAllListeners('request') proxy.on('request', (request, response) => { diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index 2017bb43ca..b7e6e4393f 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1950, + "AGENTS.md": 1960, "docs/AGENTS.md": 1320, "docs/architecture.md": 2400, "docs/cordis-primer.md": 600, From c62d6f3a444ca91ecb9c7660552193c03cb44744 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 11:01:05 +0800 Subject: [PATCH 15/43] refactor(net): make the proxy a util library with six functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review asked why this is a plugin and why it exports so much. The design note this branch shipped answered the second question itself — "a pure resolution function plus an installation function" — and the code drifted to seventeen exports and a Cordis plugin nobody approved or mounted. The plugin is gone. Transport policy has one answer per process: nothing to swap, and no scope narrower than the process to give one. Its `Config` was also the only supplier of a configuration branch, so resolution now reads the environment and nothing else — `mode`, the config-sourced fields, and the `config` policy source were unreachable the moment the plugin left. Four exports nothing outside the package used are internal again, and `currentProxyPolicy` answers with the direct policy instead of `undefined`, so `DIRECT_POLICY` no longer needs a public face. Nine functions remain, one per way a caller can need the policy; two is not reachable with six consumer seams. The package moves to `util/`. The note claimed a dependency on `undici` disqualified it from that group; the charter governs harness dependencies, not external ones, and the process note that says so predates this branch. The real blocker was the harness dependency: resolution needed one method of `LaunchEnvironmentSnapshot`, so it names a structural `EnvLookup` and the launcher passes its snapshot unchanged. `net/` is dissolved. Dropping the group's line from the repository layout also returns `AGENTS.md` to its original ceiling, so the raise the merge needed is reverted. --- ...2026-08-27-outbound-proxy-policy.i18n.yaml | 4 +- .../2026-08-27-outbound-proxy-policy.md | 10 +- .../2026-08-27-outbound-proxy-policy.zh.md | 10 +- AGENTS.md | 1 - docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 34 +------ docs/config-catalog.zh.md | 34 +------ docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 55 +++++------ docs/module-graph.zh.md | 55 +++++------ packages/README.i18n.yaml | 4 +- packages/README.md | 1 - packages/README.zh.md | 1 - packages/e2b/e2b/tsconfig.json | 2 +- packages/llm/llm-deepseek/tsconfig.json | 2 +- packages/llm/llm-pi-ai/tsconfig.json | 2 +- packages/mcp/mcp-client/tsconfig.json | 2 +- packages/net/README.md | 44 --------- packages/net/README.zh.md | 44 --------- packages/net/http-proxy/README.i18n.yaml | 6 -- packages/net/http-proxy/src/index.ts | 88 ----------------- packages/net/http-proxy/tests/plugin.spec.ts | 90 ----------------- .../session-telemetry-otel/tsconfig.json | 2 +- packages/subprocess/subprocess/tsconfig.json | 2 +- .../test-support/loader-smoke/tsconfig.json | 2 +- .../session-snapshot/tsconfig.json | 2 +- packages/util/README.i18n.yaml | 4 +- packages/util/README.md | 3 +- packages/util/README.zh.md | 3 +- .../{net => util/http-proxy}/README.i18n.yaml | 6 +- packages/{net => util}/http-proxy/README.md | 10 +- .../{net => util}/http-proxy/README.zh.md | 10 +- .../{net => util}/http-proxy/package.json | 9 +- packages/util/http-proxy/src/index.ts | 37 +++++++ .../{net => util}/http-proxy/src/install.ts | 10 +- .../{net => util}/http-proxy/src/invariant.ts | 0 .../{net => util}/http-proxy/src/policy.ts | 99 +++++-------------- .../http-proxy/tests/install.spec.ts | 28 +----- .../util/http-proxy/tests/invariant.spec.ts | 18 ++++ .../http-proxy/tests/matcher-parity.spec.ts | 0 .../http-proxy/tests/policy.spec.ts | 66 ++----------- .../{net => util}/http-proxy/tsconfig.json | 0 packages/web/web-fetch-http/src/provider.ts | 4 +- packages/web/web-fetch-http/tsconfig.json | 2 +- .../web/web-search-deepseek/tsconfig.json | 2 +- packages/web/web-search-exa/tsconfig.json | 2 +- .../web/web-search-perplexity/tsconfig.json | 2 +- .../workflow-worker-thread/tsconfig.json | 2 +- pnpm-lock.yaml | 62 ++++++------ scripts/doc-budgets.manifest.json | 2 +- scripts/test-proxy-environment.spec.ts | 2 +- scripts/test-proxy-environment.ts | 2 +- scripts/verify-no-bare-dispatcher.ts | 2 +- .../verify-package-readme-model-experience.ts | 2 +- scripts/verify-subsystem-pages.ts | 1 - tsconfig.base.json | 4 +- tsconfig.host.json | 2 +- 57 files changed, 246 insertions(+), 655 deletions(-) delete mode 100644 packages/net/README.md delete mode 100644 packages/net/README.zh.md delete mode 100644 packages/net/http-proxy/README.i18n.yaml delete mode 100644 packages/net/http-proxy/src/index.ts delete mode 100644 packages/net/http-proxy/tests/plugin.spec.ts rename packages/{net => util/http-proxy}/README.i18n.yaml (56%) rename packages/{net => util}/http-proxy/README.md (86%) rename packages/{net => util}/http-proxy/README.zh.md (87%) rename packages/{net => util}/http-proxy/package.json (77%) create mode 100644 packages/util/http-proxy/src/index.ts rename packages/{net => util}/http-proxy/src/install.ts (97%) rename packages/{net => util}/http-proxy/src/invariant.ts (100%) rename packages/{net => util}/http-proxy/src/policy.ts (78%) rename packages/{net => util}/http-proxy/tests/install.spec.ts (94%) create mode 100644 packages/util/http-proxy/tests/invariant.spec.ts rename packages/{net => util}/http-proxy/tests/matcher-parity.spec.ts (100%) rename packages/{net => util}/http-proxy/tests/policy.spec.ts (81%) rename packages/{net => util}/http-proxy/tsconfig.json (100%) 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 77039047ac..b862a09c76 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: 5c017d8c36321877981383f083b739f7b5622ccb -2026-08-27-outbound-proxy-policy.zh.md: 7efe83a75c4c04695dc422cb87365226e249be1e +2026-08-27-outbound-proxy-policy.md: c15c1d08537a5751ac57651fe7ef94e0088d0896 +2026-08-27-outbound-proxy-policy.zh.md: ee4b9b2768bd6a75565b2f09ef88c9bb334e4ed4 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 5c017d8c36..c15c1d0853 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 @@ -14,11 +14,15 @@ That sentence could not have worked anyway, for three measured reasons. `NODE_US ## 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`. +**One policy, resolved once from the launch environment, installed as the global dispatcher.** `packages/util/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. +**A library in `util/`, not a plugin.** Transport policy has one answer per process: nothing to swap, and no scope narrower than the process to give one. The package exports functions and mounts nothing — `boot`, `web`, `subprocess`, and `workflow` all consume it, and `util/` is the group every other group may depend on. + +An earlier revision put it in a new `net/` package group, reasoning that depending on `undici` disqualified it from a "zero-dependency" group. That reading was wrong: [dependencies over hand-rolling](../process/2026-07-26-dependencies-over-hand-rolling.md) records that the charter governs *harness* dependencies — util stays free of them so any group can depend on util — and does not ban external packages. What did need removing was the dependency on `dsh-launch-environment`: resolution needs one method from it, so it names a structural `EnvLookup` instead and the launcher passes its snapshot unchanged. + +The plugin that revision shipped is gone with it. It let a composition declare the policy in `cordis.yml`, but no shipped bundle mounted it, so the launcher's path was the only reachable one — and its `Config` was the sole supplier of a configuration branch nothing else could reach. **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. @@ -72,7 +76,7 @@ The suite is hermetic against the developer's own environment: `plugin.spec.ts` ## 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/util/http-proxy` holds 89 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and 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. 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 7efe83a75c..ee4b9b2768 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 @@ -14,11 +14,15 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 ## 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`。 +**一份策略,从启动环境解析一次,装为全局 dispatcher。** `packages/util/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` 都消费它,放进其中任何一组都会让另外三条依赖反向。它刻意不是能力接缝:传输策略每个进程只有一种实现、一个答案,没有可替换的对象。 +**放在 `util/` 的库,而非插件。** 传输策略每个进程只有一个答案:没有可替换的实现,也没有比进程更窄的作用域可赋予。因此本包只导出函数、不挂载任何东西——`boot`、`web`、`subprocess` 与 `workflow` 都消费它,而 `util/` 正是其他所有组都可以依赖的那一组。 + +早先的修订把它放进新建的 `net/` 包组,理由是依赖 `undici` 使它不符合“零依赖”组。那个理解是错的:[优先使用依赖而非手写](../process/2026-07-26-dependencies-over-hand-rolling.zh.md) 记录了该章程约束的是 *harness* 依赖——util 不依赖它们,任何组才都能依赖 util——并不禁止外部包。真正需要去掉的是对 `dsh-launch-environment` 的依赖:解析只用到它的一个方法,于是改为声明结构化的 `EnvLookup`,启动器原样传入自己的快照即可。 + +那次修订一并引入的插件也随之删除。它让某个组合可以把策略写进 `cordis.yml`,但没有任何随附 bundle 挂载它,因此启动器那条路径是唯一可达的——而它的 `Config` 是那条配置分支唯一的供给方,别处无从到达。 **已安装的 dispatcher 按策略路由,而不是重新解析一遍环境。** `installGlobalProxy` 构造一个 `Agent`,其按 origin 调用的 `factory` 会询问 `proxyForUrl` 该 origin 的去向,并据此返回 `ProxyAgent` 或 undici 自带的默认客户端。undici 的 `EnvHttpProxyAgent` 曾是首选,但对这套策略是错的:没有 `HTTPS_PROXY` 时它会把 HTTPS agent 设为 HTTP agent,于是本包在拒绝某个 SOCKS 或畸形 URL 后本应保持直连的 scheme 仍会被隧道转发,而诊断却声称直连。让路由走同一个谓词,从构造上而非靠测试消除了这一类分歧。把策略发布到环境中的做法保留下来,但如今只服务那些拿不到策略对象的读者:Node 的 `proxyEnv` 选项,以及每个派生的子进程。 @@ -72,7 +76,7 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l ## Testing -`packages/net/http-proxy` 有 64 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及 `mode: 'off'`;绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。 +`packages/util/http-proxy` 有 89 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。 `packages/web/web-fetch-http/tests/proxy.spec.ts` 断言了最关键的那个决定:经由代理时公网地址解析器完全不被调用,而被绕过的一跳仍恰好调用一次,且跨域重定向拒绝在代理路径上依然成立。 diff --git a/AGENTS.md b/AGENTS.md index d08270a7dc..3249baf19a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,6 @@ 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 HTTP proxy policy compaction/ compaction capability + basic provider context/ request-context plugins subagent/ subagent capability: Service Definition + providers + delegation Consumers diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index c249e2b227..a99441abec 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: ff9d14558ea616fd51c9ece71750066f713d57d2 -config-catalog.zh.md: 8237fc7fef5316d6d6e40b396665b067605d7cbb +config-catalog.md: d7644a1c7cd2e5c54d1609c0e86bf9f424e0db77 +config-catalog.zh.md: dc38567717f63ff82d32b5c5cba9962f260c5d82 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index ff9d14558e..d7644a1c7c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -924,39 +924,6 @@ 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:51`](../packages/net/http-proxy/src/index.ts) - ## `@deepseek-ai/dsh-invariants` @@ -3564,6 +3531,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-experimental-webworker-runtime` ([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) - `@deepseek-ai/dsh-home-paths` ([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) +- `@deepseek-ai/dsh-http-proxy` ([`packages/util/http-proxy/src/index.ts`](../packages/util/http-proxy/src/index.ts)) - `@deepseek-ai/dsh-launch-environment` ([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server` ([`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke` ([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8237fc7fef..dc38567717 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -926,39 +926,6 @@ 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:51`](../packages/net/http-proxy/src/index.ts) - ## `@deepseek-ai/dsh-invariants` @@ -3565,6 +3532,7 @@ export interface Config { - `@deepseek-ai/dsh-experimental-webworker-runtime`([`packages/experimental/webworker-runtime/src/index.ts`](../packages/experimental/webworker-runtime/src/index.ts)) - `@deepseek-ai/dsh-home-paths`([`packages/util/home-paths/src/index.ts`](../packages/util/home-paths/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) +- `@deepseek-ai/dsh-http-proxy`([`packages/util/http-proxy/src/index.ts`](../packages/util/http-proxy/src/index.ts)) - `@deepseek-ai/dsh-launch-environment`([`packages/util/launch-environment/src/index.ts`](../packages/util/launch-environment/src/index.ts)) - `@deepseek-ai/dsh-llm-mock-server`([`packages/test-support/llm-mock-server/src/index.ts`](../packages/test-support/llm-mock-server/src/index.ts)) - `@deepseek-ai/dsh-loader-smoke`([`packages/test-support/loader-smoke/src/index.ts`](../packages/test-support/loader-smoke/src/index.ts)) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 268523678b..5a10429249 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: 3ceb954a1fd97ba8ff1182c284295f00c7c78f1b -module-graph.zh.md: faa95926b1d858cd7f8423d38b1d7dc89a7ef073 +module-graph.md: 049a2614195cd3b26f483a21093828912983ab10 +module-graph.zh.md: 50308aeed9cce61d2e89799cb924b985cd4ba2f4 diff --git a/docs/module-graph.md b/docs/module-graph.md index 3ceb954a1f..049a261419 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -12,6 +12,7 @@ flowchart TD pkg_brand["brand"] pkg_deque["deque"] pkg_home_paths["home-paths"] + pkg_http_proxy["http-proxy"] pkg_launch_environment["launch-environment"] pkg_native_command["native-command"] pkg_output_retention["output-retention"] @@ -264,9 +265,6 @@ 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"] @@ -367,6 +365,7 @@ flowchart TD pkg_brand --> pkg_invariants pkg_deque --> pkg_invariants pkg_home_paths --> pkg_invariants + pkg_http_proxy --> pkg_invariants pkg_launch_environment --> pkg_invariants pkg_native_command --> pkg_invariants pkg_output_retention --> pkg_invariants @@ -409,6 +408,10 @@ flowchart TD pkg_skill --> pkg_invariants pkg_skill --> pkg_llm pkg_skill --> pkg_scope + 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 pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_launch_environment pkg_web_search_exa --> pkg_web @@ -426,6 +429,8 @@ 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 @@ -448,20 +453,16 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - 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 --> pkg_http_proxy + pkg_subprocess --> pkg_invariants 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 pkg_spill --> pkg_brand pkg_spill --> pkg_invariants pkg_spill --> pkg_llm @@ -477,8 +478,10 @@ flowchart TD pkg_code_runtime_worker_thread --> pkg_invariants pkg_code_runtime_worker_thread --> pkg_session pkg_code_runtime_worker_thread --> pkg_timeout - pkg_e2b --> pkg_http_proxy - pkg_e2b --> pkg_invariants + pkg_subprocess_e2b --> pkg_e2b + pkg_subprocess_e2b --> pkg_invariants + pkg_subprocess_e2b --> pkg_subprocess + pkg_subprocess_e2b --> pkg_timeout pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -496,8 +499,9 @@ flowchart TD pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_settings --> pkg_session - pkg_subprocess --> pkg_http_proxy - pkg_subprocess --> pkg_invariants + pkg_subprocess_local --> pkg_invariants + pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_session_snapshot --> pkg_http_proxy pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session @@ -514,10 +518,6 @@ flowchart TD pkg_fs --> pkg_sandbox pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_subprocess_e2b --> pkg_e2b - pkg_subprocess_e2b --> pkg_invariants - pkg_subprocess_e2b --> pkg_subprocess - pkg_subprocess_e2b --> pkg_timeout pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm @@ -552,9 +552,6 @@ flowchart TD pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings pkg_shell --> pkg_subprocess - pkg_subprocess_local --> pkg_invariants - pkg_subprocess_local --> pkg_subprocess - pkg_subprocess_local --> pkg_timeout pkg_workspace --> pkg_invariants pkg_workspace --> pkg_session pkg_workspace --> pkg_session_persistence @@ -1396,6 +1393,7 @@ flowchart TD | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`deque`](../packages/util/deque) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`home-paths`](../packages/util/home-paths) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`http-proxy`](../packages/util/http-proxy) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`launch-environment`](../packages/util/launch-environment) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1432,41 +1430,41 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`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-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/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) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`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/util/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) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`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`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`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` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`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) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`e2b`](../packages/e2b/e2b) | `e2b` | [`http-proxy`](../packages/net/http-proxy), [`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) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`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) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/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) | -| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1475,7 +1473,6 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1503,7 +1500,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), [`http-proxy`](../packages/net/http-proxy), [`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/util/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) | @@ -1544,7 +1541,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), [`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-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/util/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 faa95926b1..50308aeed9 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -14,6 +14,7 @@ flowchart TD pkg_brand["brand"] pkg_deque["deque"] pkg_home_paths["home-paths"] + pkg_http_proxy["http-proxy"] pkg_launch_environment["launch-environment"] pkg_native_command["native-command"] pkg_output_retention["output-retention"] @@ -266,9 +267,6 @@ 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"] @@ -369,6 +367,7 @@ flowchart TD pkg_brand --> pkg_invariants pkg_deque --> pkg_invariants pkg_home_paths --> pkg_invariants + pkg_http_proxy --> pkg_invariants pkg_launch_environment --> pkg_invariants pkg_native_command --> pkg_invariants pkg_output_retention --> pkg_invariants @@ -411,6 +410,10 @@ flowchart TD pkg_skill --> pkg_invariants pkg_skill --> pkg_llm pkg_skill --> pkg_scope + 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 pkg_web_search_exa --> pkg_invariants pkg_web_search_exa --> pkg_launch_environment pkg_web_search_exa --> pkg_web @@ -428,6 +431,8 @@ 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 @@ -450,20 +455,16 @@ flowchart TD pkg_lsp --> pkg_brand pkg_lsp --> pkg_invariants pkg_lsp --> pkg_llm - 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 --> pkg_http_proxy + pkg_subprocess --> pkg_invariants 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 pkg_spill --> pkg_brand pkg_spill --> pkg_invariants pkg_spill --> pkg_llm @@ -479,8 +480,10 @@ flowchart TD pkg_code_runtime_worker_thread --> pkg_invariants pkg_code_runtime_worker_thread --> pkg_session pkg_code_runtime_worker_thread --> pkg_timeout - pkg_e2b --> pkg_http_proxy - pkg_e2b --> pkg_invariants + pkg_subprocess_e2b --> pkg_e2b + pkg_subprocess_e2b --> pkg_invariants + pkg_subprocess_e2b --> pkg_subprocess + pkg_subprocess_e2b --> pkg_timeout pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -498,8 +501,9 @@ flowchart TD pkg_settings --> pkg_brand pkg_settings --> pkg_invariants pkg_settings --> pkg_session - pkg_subprocess --> pkg_http_proxy - pkg_subprocess --> pkg_invariants + pkg_subprocess_local --> pkg_invariants + pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_session_snapshot --> pkg_http_proxy pkg_session_snapshot --> pkg_invariants pkg_session_snapshot --> pkg_session @@ -516,10 +520,6 @@ flowchart TD pkg_fs --> pkg_sandbox pkg_spill_local --> pkg_invariants pkg_spill_local --> pkg_spill - pkg_subprocess_e2b --> pkg_e2b - pkg_subprocess_e2b --> pkg_invariants - pkg_subprocess_e2b --> pkg_subprocess - pkg_subprocess_e2b --> pkg_timeout pkg_message_feedback --> pkg_brand pkg_message_feedback --> pkg_invariants pkg_message_feedback --> pkg_llm @@ -554,9 +554,6 @@ flowchart TD pkg_shell --> pkg_sandbox pkg_shell --> pkg_settings pkg_shell --> pkg_subprocess - pkg_subprocess_local --> pkg_invariants - pkg_subprocess_local --> pkg_subprocess - pkg_subprocess_local --> pkg_timeout pkg_workspace --> pkg_invariants pkg_workspace --> pkg_session pkg_workspace --> pkg_session_persistence @@ -1398,6 +1395,7 @@ flowchart TD | [`brand`](../packages/util/brand) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`deque`](../packages/util/deque) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`home-paths`](../packages/util/home-paths) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`http-proxy`](../packages/util/http-proxy) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`launch-environment`](../packages/util/launch-environment) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`native-command`](../packages/util/native-command) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | | [`output-retention`](../packages/util/output-retention) | `util` | [`invariants`](../packages/runtime-diagnostics/invariants) | @@ -1434,41 +1432,41 @@ flowchart TD | [`session`](../packages/core/session) | `core` | [`scope`](../packages/core/scope) | | [`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-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/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) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`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/util/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) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`anonymous-user-id`](../packages/identity/anonymous-user-id) | `identity` | [`brand`](../packages/util/brand), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | -| [`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`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`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` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`timeout`](../packages/util/timeout), [`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) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | | [`code-runtime-worker-thread`](../packages/code-runtime/code-runtime-worker-thread) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`e2b`](../packages/e2b/e2b) | `e2b` | [`http-proxy`](../packages/net/http-proxy), [`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) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`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) | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/net/http-proxy), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/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) | -| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) | @@ -1477,7 +1475,6 @@ flowchart TD | [`session-stats`](../packages/session/session-stats) | `session` | [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) | | [`settings-file`](../packages/settings/settings-file) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | | [`shell`](../packages/shell/shell) | `shell` | [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/runtime-diagnostics/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`workspace`](../packages/workspace/workspace) | `workspace` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage`](../packages/storage/storage), [`storage-domain`](../packages/storage/storage-domain), [`typert-protocol`](../packages/typert/protocol) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`atomic-write`](../packages/util/atomic-write), [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`authorization`](../packages/credentials/authorization), [`credentials`](../packages/credentials/credentials), [`fs`](../packages/fs/fs), [`invariants`](../packages/runtime-diagnostics/invariants), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1505,7 +1502,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), [`http-proxy`](../packages/net/http-proxy), [`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/util/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) | @@ -1546,7 +1543,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), [`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-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/util/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/packages/README.i18n.yaml b/packages/README.i18n.yaml index 9abca61d62..158a9ead5c 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: 032c18f301846b6e80c89b5f208e344d954b336b -README.zh.md: 86c3ce9b19812bcb21c6bb7440ee96f5075922f4 +README.md: e1a729a6c80d8f8125e0d4c4b038dc631edd7a26 +README.zh.md: 754ad0fc44677afb243c3a32a0281f58ec3b3af2 diff --git a/packages/README.md b/packages/README.md index 032c18f301..e1a729a6c8 100644 --- a/packages/README.md +++ b/packages/README.md @@ -53,7 +53,6 @@ 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 86c3ce9b19..754ad0fc44 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -53,7 +53,6 @@ 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/tsconfig.json b/packages/e2b/e2b/tsconfig.json index 304c40efd8..e362ead0c7 100644 --- a/packages/e2b/e2b/tsconfig.json +++ b/packages/e2b/e2b/tsconfig.json @@ -24,7 +24,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" }, { "path": "../../util/launch-environment" diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index d072109e35..f0b069781c 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -57,7 +57,7 @@ "path": "../../identity/anonymous-user-id" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index d64a11586c..e611c1dab2 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -48,7 +48,7 @@ "path": "../../util/timeout" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index e98771f654..2181b56333 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -36,7 +36,7 @@ "path": "../../util/timeout" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/net/README.md b/packages/net/README.md deleted file mode 100644 index 21aa924afd..0000000000 --- a/packages/net/README.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -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 deleted file mode 100644 index 9dec251f6e..0000000000 --- a/packages/net/README.zh.md +++ /dev/null @@ -1,44 +0,0 @@ ---- -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 deleted file mode 100644 index 644b4d8cba..0000000000 --- a/packages/net/http-proxy/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# 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: d07ca2fceced2adfa4d788462f8e04894d87c99a -README.zh.md: 74d256a9d9f2b7d00ea01b5bf50a9b84279511fd diff --git a/packages/net/http-proxy/src/index.ts b/packages/net/http-proxy/src/index.ts deleted file mode 100644 index a7d58102f5..0000000000 --- a/packages/net/http-proxy/src/index.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * 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, - isLoopbackHost, - describeProxyPolicy, - proxyForUrl, - resolveProxyPolicy, - DIRECT_POLICY, - LOOPBACK_NO_PROXY, - PROXY_ENV_NAMES, - 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/tests/plugin.spec.ts b/packages/net/http-proxy/tests/plugin.spec.ts deleted file mode 100644 index 3059eed8a5..0000000000 --- a/packages/net/http-proxy/tests/plugin.spec.ts +++ /dev/null @@ -1,90 +0,0 @@ -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' - -// `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 of PROXY_ENV_NAMES) Reflect.deleteProperty(process.env, name) -}) - -/** 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/session/session-telemetry-otel/tsconfig.json b/packages/session/session-telemetry-otel/tsconfig.json index 921c563277..e8c901ddcb 100644 --- a/packages/session/session-telemetry-otel/tsconfig.json +++ b/packages/session/session-telemetry-otel/tsconfig.json @@ -36,7 +36,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/subprocess/subprocess/tsconfig.json b/packages/subprocess/subprocess/tsconfig.json index d5517e666f..da5fb4bc7a 100644 --- a/packages/subprocess/subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -18,7 +18,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" }, { "path": "../../util/launch-environment" diff --git a/packages/test-support/loader-smoke/tsconfig.json b/packages/test-support/loader-smoke/tsconfig.json index f4e19d079f..afa02b40c1 100644 --- a/packages/test-support/loader-smoke/tsconfig.json +++ b/packages/test-support/loader-smoke/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/test-support/session-snapshot/tsconfig.json b/packages/test-support/session-snapshot/tsconfig.json index 8874db4c1b..b74f5b8dbe 100644 --- a/packages/test-support/session-snapshot/tsconfig.json +++ b/packages/test-support/session-snapshot/tsconfig.json @@ -21,7 +21,7 @@ "path": "../../core/session" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/util/README.i18n.yaml b/packages/util/README.i18n.yaml index 56fa6e1d68..35defcb799 100644 --- a/packages/util/README.i18n.yaml +++ b/packages/util/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/util/README.md -README.md: f3ce5f7ad65e2faa75b6e1141b73c537c9fab601 -README.zh.md: 308f887f0a26b489f5796ff953127b364be956b9 +README.md: ab201cfbc33a51e1713804532f9c6d0a5003c19c +README.zh.md: 751370c822a05da1b050d96bdcf9afbee1d0ea39 diff --git a/packages/util/README.md b/packages/util/README.md index f3ce5f7ad6..ab201cfbc3 100644 --- a/packages/util/README.md +++ b/packages/util/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, native commands, output retention, time-zone canonicalization, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics. +The `util/` group gives capability packages shared mechanical primitives instead of duplicate implementations. It covers atomic writes, branded ids, deques, lossless JSON values, UUIDs, Harness-home paths, launch environments, outbound proxy policy, native commands, output retention, time-zone canonicalization, and timeout handling. Every root entry here is a library: it registers no product service or event, and the consuming capability retains the business semantics. ## Table of Contents @@ -31,6 +31,7 @@ Each package provides one primitive; open a package page for how to use it. | [`deque/`](deque/README.md) | Provides amortized constant-time queue operations with bounded vacant storage | | [`values/`](values/README.md) | Validates, snapshots, compares, and freezes lossless JSON-compatible values | | [`home-paths/`](home-paths/README.md) | Resolves the single Harness home and joins shared user-data paths | +| [`http-proxy/`](http-proxy/README.md) | Resolves one outbound proxy policy and installs it for `fetch`, SDK agents, and spawned children | | [`launch-environment/`](launch-environment/README.md) | Frozen launch environment that remembers which layer supplied each value | | [`atomic-write/`](atomic-write/README.md) | Atomic file replacement and cross-process writer locking | | [`native-command/`](native-command/README.md) | Runs host-native commands directly, never through a shell string | diff --git a/packages/util/README.zh.md b/packages/util/README.zh.md index 308f887f0a..751370c822 100644 --- a/packages/util/README.zh.md +++ b/packages/util/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、双端队列、无损 JSON 值、UUID、Harness home 路径、启动环境、原生命令、输出保留、时区规范化和超时处理。这里的每个根入口都是库:它不注册产品服务或事件,业务语义仍由消费它的能力负责。 +`util/` 组为能力包提供共享的机制原语,避免重复实现。它涵盖原子写入、品牌化 id、双端队列、无损 JSON 值、UUID、Harness home 路径、启动环境、出站代理策略、原生命令、输出保留、时区规范化和超时处理。这里的每个根入口都是库:它不注册产品服务或事件,业务语义仍由消费它的能力负责。 ## 目录 @@ -31,6 +31,7 @@ kind: "package-group" | [`deque/`](deque/README.zh.md) | 提供摊销常数时间的队列操作和有界空闲存储 | | [`values/`](values/README.zh.md) | 校验、创建快照、比较和冻结无损 JSON 兼容值 | | [`home-paths/`](home-paths/README.zh.md) | 解析统一的 Harness 主目录并拼接共享的用户数据路径 | +| [`http-proxy/`](http-proxy/README.zh.md) | 解析出唯一的出站代理策略,并为 `fetch`、SDK agent 与派生子进程安装它 | | [`launch-environment/`](launch-environment/README.zh.md) | 冻结的启动环境,记住每个值来自哪一层 | | [`atomic-write/`](atomic-write/README.zh.md) | 原子文件替换与跨进程写锁 | | [`native-command/`](native-command/README.zh.md) | 直接运行宿主原生命令,绝不拼 shell 字符串 | diff --git a/packages/net/README.i18n.yaml b/packages/util/http-proxy/README.i18n.yaml similarity index 56% rename from packages/net/README.i18n.yaml rename to packages/util/http-proxy/README.i18n.yaml index 8f749438c8..5900300f05 100644 --- a/packages/net/README.i18n.yaml +++ b/packages/util/http-proxy/README.i18n.yaml @@ -1,6 +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 +# pnpm run verify-translation-pairing --write packages/util/http-proxy/README.md +README.md: d8eb4383c44deededef9d2ed7286789463417fc8 +README.zh.md: c5f82fe55656338b8581742c63e8c4078e42f8d0 diff --git a/packages/net/http-proxy/README.md b/packages/util/http-proxy/README.md similarity index 86% rename from packages/net/http-proxy/README.md rename to packages/util/http-proxy/README.md index d07ca2fcec..d8eb4383c4 100644 --- a/packages/net/http-proxy/README.md +++ b/packages/util/http-proxy/README.md @@ -25,7 +25,7 @@ Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`, so a harness beh ## 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. +Nothing to mount, and nothing to configure. 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. This is a library rather than a plugin because transport policy has one answer per process: there is no second implementation to swap and no scope narrower than the process to give one. ### Writing a new outbound call @@ -50,7 +50,7 @@ Loopback is always bypassed — `localhost`, the whole `127.0.0.0/8` range, `::1 ### 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. +A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable string, an unsupported scheme — is reported and skipped, and that scheme connects directly. The variable may have been exported for other tools, so it must not stop the agent from starting. ----- @@ -61,7 +61,7 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri **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 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. +**A child inherits the user's own values, and the resolved policy for what they left unset.** A scheme the user named in either casing reaches a child exactly as they wrote it, so a SOCKS proxy `curl` uses is never replaced by an HTTP one named for another scheme. A scheme they named in neither casing carries the resolved value instead, because otherwise the child's routing diverges from its parent's: Node's `NODE_USE_ENV_PROXY` does not read `ALL_PROXY`. The bypass list is always the resolved one — it only ever adds the loopback entries, so nothing the user wrote is lost. The cost of one routing answer for parent and child alike is that `curl` also sees the `https:` proxy this package derives from the HTTP one. ### Source map @@ -69,7 +69,7 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri |---|---| | `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. | +| `src/index.ts` | The package face: six functions and the types they use. | ### Bypass matching @@ -101,7 +101,7 @@ 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 that scheme stays direct rather than borrowing another scheme's proxy. +- **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment. A macOS or Windows system-proxy setting is not read, so a user who only toggled it in a proxy application must still export the variables; a SOCKS URL is reported and that scheme stays direct rather than borrowing another scheme's proxy. - **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 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. diff --git a/packages/net/http-proxy/README.zh.md b/packages/util/http-proxy/README.zh.md similarity index 87% rename from packages/net/http-proxy/README.zh.md rename to packages/util/http-proxy/README.zh.md index 74d256a9d9..c5f82fe556 100644 --- a/packages/net/http-proxy/README.zh.md +++ b/packages/util/http-proxy/README.zh.md @@ -25,7 +25,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代 ## 使用本包 -无需挂载。`dsh` 启动器会在第一个插件加载之前,为每个 profile 解析并安装策略,因此导出了 `HTTPS_PROXY` 的用户在所有位置都会走代理。只有当某个组合希望把策略写在 `cordis.yml` 而非环境中时,才需要挂载本插件。 +无需挂载,也无需配置。`dsh` 启动器会在第一个插件加载之前,为每个 profile 解析并安装策略,因此导出了 `HTTPS_PROXY` 的用户在所有位置都会走代理。本包是库而非插件,因为传输策略每个进程只有一个答案:没有第二个实现可替换,也没有比进程更窄的作用域可赋予。 ### 编写新的出站调用 @@ -50,7 +50,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` ### 失败处理 -本包无法使用的代理值——SOCKS 或 PAC URL、无法解析的字符串、不受支持的协议——会被报告并跳过,进程转为直连。该变量可能是用户为其他工具导出的,不应因此阻止 agent 启动。同样的值若通过本插件的 `Config` 提供,则在加载期抛出:那是 Harness 自己的配置面,笔误必须立刻响。 +本包无法使用的代理值——SOCKS 或 PAC URL、无法解析的字符串、不受支持的协议——会被报告并跳过,该 scheme 转为直连。该变量可能是用户为其他工具导出的,不应因此阻止 agent 启动。 ----- @@ -61,7 +61,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` **一次解析,一个匹配器。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此该 dispatcher 是一个 `Agent`,其按 origin 调用的 `factory` 自身调用 `proxyForUrl()`,不存在可能与第一个解析器产生漂移的第二个解析器。undici 的 `EnvHttpProxyAgent` 在此无法胜任:没有 `HTTPS_PROXY` 时它让 `https:` 复用 HTTP 代理,于是本包在拒绝用户为该 scheme 指定的 URL 后本应保持直连的 scheme 仍会被隧道转发。 -**子进程继承用户自己的值,以及用户未设置部分的解析结果。** 用户以任一大小写指定过的 scheme,会以他们书写的形式原样传给子进程,因此用户为 `curl` 设置的 SOCKS 代理绝不会被替换成为其他 scheme 指定的 HTTP 代理。两种大小写都未指定的 scheme 则携带解析值,否则子进程的路由会与父进程分歧:Node 的 `NODE_USE_ENV_PROXY` 既不读 `ALL_PROXY`,也不读来自 `cordis.yml` 的代理。绕过列表始终采用解析结果——它只会追加 loopback 条目,用户写下的内容不会丢失。让父子进程只有一个路由答案的代价是:`curl` 也会看到本包由 HTTP 代理推导出的 `https:` 代理。 +**子进程继承用户自己的值,以及用户未设置部分的解析结果。** 用户以任一大小写指定过的 scheme,会以他们书写的形式原样传给子进程,因此用户为 `curl` 设置的 SOCKS 代理绝不会被替换成为其他 scheme 指定的 HTTP 代理。两种大小写都未指定的 scheme 则携带解析值,否则子进程的路由会与父进程分歧:Node 的 `NODE_USE_ENV_PROXY` 不读 `ALL_PROXY`。绕过列表始终采用解析结果——它只会追加 loopback 条目,用户写下的内容不会丢失。让父子进程只有一个路由答案的代价是:`curl` 也会看到本包由 HTTP 代理推导出的 `https:` 代理。 ### 源码地图 @@ -69,7 +69,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` |---|---| | `src/policy.ts` | 解析、绕过匹配与脱敏。不引入任何传输实现,因此在没有 undici 的环境中仍可加载。 | | `src/install.ts` | 全局 dispatcher、生效策略记录、`createDispatcher` 与 `childProxyEnv`。动态引入 undici。 | -| `src/index.ts` | 重导出两半,以及可选的 Cordis 插件。 | +| `src/index.ts` | 本包的对外面:六个函数及其使用的类型。 | ### 绕过匹配 @@ -101,7 +101,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` 这些限制界定了本包不适用的场景,属于当前的包级约束。 -- **不支持 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 的 `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`,而两者运行的都是模型写的脚本。 diff --git a/packages/net/http-proxy/package.json b/packages/util/http-proxy/package.json similarity index 77% rename from packages/net/http-proxy/package.json rename to packages/util/http-proxy/package.json index 70373773e1..af0863b6f1 100644 --- a/packages/net/http-proxy/package.json +++ b/packages/util/http-proxy/package.json @@ -8,7 +8,7 @@ "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", - "directory": "packages/net/http-proxy" + "directory": "packages/util/http-proxy" }, "type": "module", "main": "lib/index.js", @@ -33,16 +33,13 @@ "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-launch-environment": "workspace:^" + "@deepseek-ai/dsh-invariants": "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:^" + "@deepseek-ai/dsh-invariants": "workspace:^" } } diff --git a/packages/util/http-proxy/src/index.ts b/packages/util/http-proxy/src/index.ts new file mode 100644 index 0000000000..5b40b55bd9 --- /dev/null +++ b/packages/util/http-proxy/src/index.ts @@ -0,0 +1,37 @@ +/** + * 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 library 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 resolves and installs once, before the first plugin mounts. This is a library, not a + * plugin: transport policy has one answer per process, so there is nothing for a composition to + * mount, swap, or scope. + * + * Six exports, one per way a caller can need the policy: resolve it, install it, get a dispatcher + * for a request, get a `node:http` agent for an SDK that takes one, get a proxy URL for an SDK that + * takes that, and get the environment a spawned child needs. + * @module @deepseek-ai/dsh-http-proxy + */ + +export { + proxyForUrl, + resolveProxyPolicy, + PROXY_ENV_NAMES, + type EnvLookup, + type ProxyDiagnostic, + type ProxyPolicy, + type ProxyResolution, +} from './policy.ts' + +export { + childProxyEnv, + createDispatcher, + createNodeHttpAgent, + currentProxyPolicy, + installGlobalProxy, + proxyUrlFor, +} from './install.ts' diff --git a/packages/net/http-proxy/src/install.ts b/packages/util/http-proxy/src/install.ts similarity index 97% rename from packages/net/http-proxy/src/install.ts rename to packages/util/http-proxy/src/install.ts index 9aa37d6e75..0016390cea 100644 --- a/packages/net/http-proxy/src/install.ts +++ b/packages/util/http-proxy/src/install.ts @@ -30,11 +30,13 @@ let inheritedProxyEnv: Readonly> | 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}. + * A caller that branches on the answer must hold this value and pass it back to + * {@link createDispatcher}: reading it twice lets an install or disposal land between the two reads. + * + * @returns the installed policy, or the direct one when {@link installGlobalProxy} has not run. */ -export function currentProxyPolicy(): ProxyPolicy | undefined { - return active +export function currentProxyPolicy(): ProxyPolicy { + return active ?? DIRECT_POLICY } /** diff --git a/packages/net/http-proxy/src/invariant.ts b/packages/util/http-proxy/src/invariant.ts similarity index 100% rename from packages/net/http-proxy/src/invariant.ts rename to packages/util/http-proxy/src/invariant.ts diff --git a/packages/net/http-proxy/src/policy.ts b/packages/util/http-proxy/src/policy.ts similarity index 78% rename from packages/net/http-proxy/src/policy.ts rename to packages/util/http-proxy/src/policy.ts index 178f7d85f8..bae976e50c 100644 --- a/packages/net/http-proxy/src/policy.ts +++ b/packages/util/http-proxy/src/policy.ts @@ -8,7 +8,19 @@ * @module @deepseek-ai/dsh-http-proxy/policy */ -import type { LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +/** + * The one thing resolution needs from an environment: a name in, the winning value out. The + * launcher's snapshot satisfies this structurally and is passed unchanged, so this module names no + * package to describe its input — and a test builds one from an object literal. + */ +export interface EnvLookup { + /** + * Resolve one variable name. + * @param name - the variable name. + * @returns the winning entry, or `undefined` when nothing supplies it. + */ + get(name: string): { readonly value: string } | undefined +} /** * Loopback entries merged into every policy's `noProxy`. A proxy that also serves the harness's own @@ -60,7 +72,7 @@ export interface ProxyPolicy { /** 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' + readonly source: 'env' | 'none' } /** A policy that proxies nothing. Callers that have not installed a policy resolve URLs against this. */ @@ -76,27 +88,6 @@ export interface ProxyDiagnostic { 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 { @@ -116,7 +107,7 @@ export interface ProxyResolution { * @returns the trimmed value and the name that supplied it, or `undefined` when neither is set. */ function readEnv( - env: LaunchEnvironmentSnapshot, + env: EnvLookup, lower: string, ): { value: string; name: string } | undefined { for (const name of [lower, lower.toUpperCase()]) { @@ -292,50 +283,29 @@ export function bypassesProxy(noProxy: string, url: URL): boolean { /** * 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. + * A scheme's own variable wins, then `ALL_PROXY`, then — for HTTPS only — the HTTP proxy, 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. + * @param env - the launch environment, whose own layering already prefers real variables over `.env` files. * @returns the policy to install plus every rejected candidate. */ -export function resolveProxyPolicy( - env: LaunchEnvironmentSnapshot, - config: ProxyConfig = {}, -): ProxyResolution { +export function resolveProxyPolicy(env: EnvLookup): ProxyResolution { const diagnostics: ProxyDiagnostic[] = [] - if (config.mode === 'off') return { policy: DIRECT_POLICY, diagnostics } - const all = acceptProxyUrl(readEnv(env, 'all_proxy'), diagnostics) const allValue = all.kind === 'accepted' ? all.value : undefined - const configHttp = acceptProxyUrl( - config.httpProxy === undefined ? undefined : { value: config.httpProxy, name: 'config.httpProxy' }, - diagnostics, - ) - 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 envHttp = acceptProxyUrl(readEnv(env, 'http_proxy'), diagnostics) const envHttps = acceptProxyUrl(readEnv(env, 'https_proxy'), diagnostics) - const httpProxy = resolveScheme(envHttp, allValue, configHttpValue) + const httpProxy = resolveScheme(envHttp, allValue) // 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) + const httpsProxy = resolveScheme(envHttps, allValue, httpProxy) if (httpProxy === undefined && httpsProxy === undefined) return { policy: DIRECT_POLICY, diagnostics } - - const noProxy = withLoopback(readEnv(env, 'no_proxy')?.value ?? config.noProxy) - const fromEnv = envHttp.kind === 'accepted' || envHttps.kind === 'accepted' || all.kind === 'accepted' return { policy: { ...httpProxy === undefined ? {} : { httpProxy }, ...httpsProxy === undefined ? {} : { httpsProxy }, - noProxy, - source: fromEnv ? 'env' : 'config', + noProxy: withLoopback(readEnv(env, 'no_proxy')?.value), + source: 'env', }, diagnostics, } @@ -357,26 +327,3 @@ export function proxyForUrl(policy: ProxyPolicy, url: URL): string | undefined { if (isLoopbackHost(url.hostname)) 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/util/http-proxy/tests/install.spec.ts similarity index 94% rename from packages/net/http-proxy/tests/install.spec.ts rename to packages/util/http-proxy/tests/install.spec.ts index 3f81e29b2f..cb43ca4c1c 100644 --- a/packages/net/http-proxy/tests/install.spec.ts +++ b/packages/util/http-proxy/tests/install.spec.ts @@ -141,7 +141,9 @@ describe('installGlobalProxy', () => { await dispose() delete process.env.HTTP_PROXY } - expect(currentProxyPolicy()).toBeUndefined() + // With nothing installed the accessor still answers, so a caller never has to spell the direct + // case itself — that spelling is what let two reads disagree about one request. + expect(currentProxyPolicy()).toBe(DIRECT_POLICY) }) 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 @@ -287,30 +289,6 @@ describe('childProxyEnv', () => { } }) - 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) diff --git a/packages/util/http-proxy/tests/invariant.spec.ts b/packages/util/http-proxy/tests/invariant.spec.ts new file mode 100644 index 0000000000..7d296960f4 --- /dev/null +++ b/packages/util/http-proxy/tests/invariant.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import InvariantRegistry from '@deepseek-ai/dsh-invariants' +import * as HttpProxyInvariant from '../src/invariant.ts' + +describe('http-proxy invariant companion', () => { + it('registers its explained empty runtime invariant', async () => { + const ctx = new Context() + await ctx.plugin(InvariantRegistry) + const fiber = await ctx.plugin(HttpProxyInvariant) + + expect(() => { + ctx.invariants.register('@deepseek-ai/dsh-http-proxy', () => {}) + }).toThrow(/already registered/) + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/net/http-proxy/tests/matcher-parity.spec.ts b/packages/util/http-proxy/tests/matcher-parity.spec.ts similarity index 100% rename from packages/net/http-proxy/tests/matcher-parity.spec.ts rename to packages/util/http-proxy/tests/matcher-parity.spec.ts diff --git a/packages/net/http-proxy/tests/policy.spec.ts b/packages/util/http-proxy/tests/policy.spec.ts similarity index 81% rename from packages/net/http-proxy/tests/policy.spec.ts rename to packages/util/http-proxy/tests/policy.spec.ts index abce8ae29f..0746cf9adb 100644 --- a/packages/net/http-proxy/tests/policy.spec.ts +++ b/packages/util/http-proxy/tests/policy.spec.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { bypassesProxy, - describeProxyPolicy, isLoopbackHost, proxyForUrl, resolveProxyPolicy, @@ -82,6 +81,16 @@ describe('resolveProxyPolicy', () => { expect(policy.httpProxy).toBe(PROXY) }) + it('leaves http direct when only the https variable is set', () => { + // The reverse of the HTTP-only case: the fallback runs one way, so naming only HTTPS proxies + // that scheme alone and every `http:` request stays direct. + const { policy } = resolveProxyPolicy(env({ HTTPS_PROXY: PROXY })) + expect(policy.httpProxy).toBeUndefined() + expect(policy.httpsProxy).toBe(PROXY) + expect(proxyForUrl(policy, new URL('http://example.com/'))).toBeUndefined() + expect(proxyForUrl(policy, new URL('https://example.com/'))).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) @@ -147,36 +156,6 @@ describe('resolveProxyPolicy', () => { 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('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) - 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', () => { @@ -249,28 +228,3 @@ describe('proxyForUrl', () => { 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/util/http-proxy/tsconfig.json similarity index 100% rename from packages/net/http-proxy/tsconfig.json rename to packages/util/http-proxy/tsconfig.json diff --git a/packages/web/web-fetch-http/src/provider.ts b/packages/web/web-fetch-http/src/provider.ts index 865977713f..01568ce6ed 100644 --- a/packages/web/web-fetch-http/src/provider.ts +++ b/packages/web/web-fetch-http/src/provider.ts @@ -10,7 +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 { currentProxyPolicy, proxyForUrl } from '@deepseek-ai/dsh-http-proxy' import { isNonPublicIpLiteral, publicHttpNetwork } from './network.ts' import type { PublicAddress } from './network.ts' import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' @@ -132,7 +132,7 @@ export class HttpFetchProvider implements WebFetchProvider { // An IP literal the address checks would refuse never takes it. The proxy would resolve // nothing — the address is already stated — so the shortcut would spend the checks for // nothing and let a proxy on this machine reach the very service they keep out of reach. - const policy = currentProxyPolicy() ?? DIRECT_POLICY + const policy = currentProxyPolicy() if (proxyForUrl(policy, url) !== undefined && !isNonPublicIpLiteral(url.hostname)) { return await publicHttpNetwork.requestProxied(url, headers, signal, policy) } diff --git a/packages/web/web-fetch-http/tsconfig.json b/packages/web/web-fetch-http/tsconfig.json index 6d97b71578..07ed66039b 100644 --- a/packages/web/web-fetch-http/tsconfig.json +++ b/packages/web/web-fetch-http/tsconfig.json @@ -27,7 +27,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/web/web-search-deepseek/tsconfig.json b/packages/web/web-search-deepseek/tsconfig.json index 0298dad5be..49f99726cf 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -39,7 +39,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/web/web-search-exa/tsconfig.json b/packages/web/web-search-exa/tsconfig.json index e3274421f5..f7b889d07f 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -27,7 +27,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/web/web-search-perplexity/tsconfig.json b/packages/web/web-search-perplexity/tsconfig.json index e3274421f5..f7b889d07f 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -27,7 +27,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/packages/workflow/workflow-worker-thread/tsconfig.json b/packages/workflow/workflow-worker-thread/tsconfig.json index 17b9cf245f..d1b3568bdc 100644 --- a/packages/workflow/workflow-worker-thread/tsconfig.json +++ b/packages/workflow/workflow-worker-thread/tsconfig.json @@ -42,7 +42,7 @@ "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../net/http-proxy" + "path": "../../util/http-proxy" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dccdbd5532..ddeea0bf75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -200,7 +200,7 @@ importers: version: link:../../packages/hooks/hooks-codex '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../packages/net/http-proxy + version: link:../../packages/util/http-proxy '@deepseek-ai/dsh-jobs-local': specifier: workspace:^ version: link:../../packages/jobs/jobs-local @@ -4667,7 +4667,7 @@ importers: version: link:../fs-e2b '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6542,7 +6542,7 @@ importers: version: link:../../util/home-paths '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6600,7 +6600,7 @@ importers: version: link:../../fs/fs '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6891,7 +6891,7 @@ importers: version: link:../../attachment/attachment-local '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -6917,25 +6917,6 @@ 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: @@ -7802,7 +7783,7 @@ importers: version: link:../../feedback/command-feedback '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9379,7 +9360,7 @@ importers: version: link:../../../vendor/cordis '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9704,7 +9685,7 @@ importers: version: link:../../boot/app-boot '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9747,7 +9728,7 @@ importers: version: link:../../compaction/compaction '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9942,6 +9923,19 @@ importers: specifier: workspace:^ version: link:../../runtime-diagnostics/invariants + packages/util/http-proxy: + dependencies: + 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 + packages/util/launch-environment: devDependencies: '@deepseek-ai/cordis': @@ -10096,7 +10090,7 @@ importers: version: link:../../../vendor/cordis '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -10127,7 +10121,7 @@ importers: version: link:../../credentials/credentials-local '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -10155,7 +10149,7 @@ importers: version: link:../../../vendor/cordis '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -10177,7 +10171,7 @@ importers: version: link:../../../vendor/cordis '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -10413,7 +10407,7 @@ importers: version: link:../../test-support/agent-loop-testkit '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../net/http-proxy + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -10603,7 +10597,7 @@ importers: version: link:../../packages/hooks/hooks-codex '@deepseek-ai/dsh-http-proxy': specifier: workspace:^ - version: link:../../packages/net/http-proxy + version: link:../../packages/util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../packages/runtime-diagnostics/invariants diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index b7e6e4393f..2017bb43ca 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,5 +1,5 @@ { - "AGENTS.md": 1960, + "AGENTS.md": 1950, "docs/AGENTS.md": 1320, "docs/architecture.md": 2400, "docs/cordis-primer.md": 600, diff --git a/scripts/test-proxy-environment.spec.ts b/scripts/test-proxy-environment.spec.ts index 5e250f03ae..8430f6436d 100644 --- a/scripts/test-proxy-environment.spec.ts +++ b/scripts/test-proxy-environment.spec.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' -import { PROXY_ENV_NAMES } from '../packages/net/http-proxy/src/policy.ts' +import { PROXY_ENV_NAMES } from '../packages/util/http-proxy/src/policy.ts' import { clearAmbientProxyEnv, TEST_PROXY_SETUP_FILE, vitestConfigFiles } from './test-proxy-environment.ts' describe('ambient proxy environment', () => { diff --git a/scripts/test-proxy-environment.ts b/scripts/test-proxy-environment.ts index 06c4dd9473..f8111ae689 100644 --- a/scripts/test-proxy-environment.ts +++ b/scripts/test-proxy-environment.ts @@ -25,7 +25,7 @@ import { globSync } from 'node:fs' import { resolve } from 'node:path' -import { PROXY_ENV_NAMES } from '../packages/net/http-proxy/src/policy.ts' +import { PROXY_ENV_NAMES } from '../packages/util/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' diff --git a/scripts/verify-no-bare-dispatcher.ts b/scripts/verify-no-bare-dispatcher.ts index 65973d8a04..ec20c9c3ea 100644 --- a/scripts/verify-no-bare-dispatcher.ts +++ b/scripts/verify-no-bare-dispatcher.ts @@ -23,7 +23,7 @@ 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/' +export const DISPATCHER_OWNER = 'packages/util/http-proxy/' /** * A comment carrying this marker states why the construction or option is exempt. It counts on the diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1b8ffaa248..f4c3f13ea2 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -64,7 +64,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/util/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 817d66233c..2906616af1 100644 --- a/scripts/verify-subsystem-pages.ts +++ b/scripts/verify-subsystem-pages.ts @@ -20,7 +20,6 @@ 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 29979cb227..789d24b02c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -342,8 +342,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-http-proxy": ["./packages/util/http-proxy/src"], + "@deepseek-ai/dsh-http-proxy/invariant": ["./packages/util/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 465a40b77a..3d5b84d438 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -128,7 +128,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, - { "path": "./packages/net/http-proxy" }, + { "path": "./packages/util/http-proxy" }, { "path": "./packages/util/launch-environment" }, { "path": "./packages/util/native-command" }, { "path": "./packages/util/home-paths" }, From 8470ddef1d033473ce7e4a8c94be0e500f5abc5f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 21:15:04 +0800 Subject: [PATCH 16/43] refactor(http-proxy): converge the proxy API on four functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The package exported six functions, four of them shaped by one SDK's transport each: a dispatcher factory, a `node:http` agent factory, a proxy-URL lookup, and a policy accessor. Review asked whether the call sites could converge instead of the package growing an export per SDK. They could, and each removal took a whole shape with it: - The OTLP exporter moves to the SDK's `fetch` delegate, retiring `createNodeHttpAgent`. Its Node-version floor goes too: `proxyEnv` on an `http.Agent` needs 22.21 or 24.5, inside the engines range, so telemetry was direct on 22.19, 22.20, and 24.0-24.4. The cost is `compression`, a Node-transport option; the plugin now refuses it, `keepAlive`, and `httpAgentOptions` at load instead of ignoring them. - `web-fetch-http` builds its own address-pinning agent under an annotated `proxy-exempt:` exemption, retiring `createDispatcher`. Pinning is per-request state a process-wide dispatcher cannot hold. - E2B reads `route.proxy`, retiring `proxyUrlFor`. What remains is `installProxyFromEnvironment`, `proxyRouteFor`, `proxyEnvironmentForChild`, and `clearedProxyEnv` — one per way a caller can need the policy. Installation absorbs resolution and diagnostic reporting, which no caller needed apart. `proxyRouteFor` also closes a defect the old accessor made expressible: `web-fetch-http` read the policy to decide whether to pin, then read it again to build a transport, so an unmount between the two returned a direct, unpinned agent for a URL the first read had cleared as proxied. A route carries the answer and the transport that answer assumed. Every egress spec now installs through `installProxyFromEnvironment`, so no test asserts a policy object a real launch could not produce. --- ...2026-08-27-outbound-proxy-policy.i18n.yaml | 4 +- .../2026-08-27-outbound-proxy-policy.md | 24 +- .../2026-08-27-outbound-proxy-policy.zh.md | 24 +- THIRD_PARTY_NOTICES.md | 2 +- apps/cli/src/profile-boot.ts | 11 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 16 +- docs/config-catalog.zh.md | 16 +- docs/user/guide/network-proxy.i18n.yaml | 4 +- docs/user/guide/network-proxy.md | 1 - docs/user/guide/network-proxy.zh.md | 1 - packages/e2b/e2b/src/index.ts | 6 +- packages/e2b/e2b/tests/egress.spec.ts | 24 +- .../llm/llm-deepseek/tests/egress.spec.ts | 9 +- packages/llm/llm-pi-ai/tests/egress.spec.ts | 9 +- packages/mcp/mcp-client/tests/egress.spec.ts | 9 +- .../session-telemetry-otel/package.json | 4 +- .../session-telemetry-otel/src/index.ts | 68 ++- .../tests/egress.spec.ts | 72 +-- .../session-telemetry-otel/tests/otel.spec.ts | 27 +- packages/subprocess/subprocess/src/index.ts | 4 +- .../subprocess/tests/egress.spec.ts | 32 +- .../test-support/loader-smoke/src/index.ts | 10 +- .../session-snapshot/src/harness.ts | 10 +- packages/util/http-proxy/README.i18n.yaml | 4 +- packages/util/http-proxy/README.md | 23 +- packages/util/http-proxy/README.zh.md | 25 +- packages/util/http-proxy/src/index.ts | 36 +- packages/util/http-proxy/src/install.ts | 163 +++---- .../util/http-proxy/tests/install.spec.ts | 429 ++++++++---------- .../http-proxy/tests/matcher-parity.spec.ts | 21 +- packages/web/web-fetch-http/src/network.ts | 112 ++--- packages/web/web-fetch-http/src/provider.ts | 15 +- .../web/web-fetch-http/tests/proxy.spec.ts | 26 +- .../web-search-deepseek/tests/egress.spec.ts | 9 +- .../web/web-search-exa/tests/egress.spec.ts | 9 +- .../tests/egress.spec.ts | 9 +- .../tests/egress.spec.ts | 18 +- pnpm-lock.yaml | 19 +- scripts/verify-no-bare-dispatcher.spec.ts | 2 +- scripts/verify-no-bare-dispatcher.ts | 7 +- 41 files changed, 635 insertions(+), 683 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 b862a09c76..f1a7262652 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: c15c1d08537a5751ac57651fe7ef94e0088d0896 -2026-08-27-outbound-proxy-policy.zh.md: ee4b9b2768bd6a75565b2f09ef88c9bb334e4ed4 +2026-08-27-outbound-proxy-policy.md: 88bfe542d322d2caee5f5e220ed211e0169fa614 +2026-08-27-outbound-proxy-policy.zh.md: f5afee9de40e2032cfd4eda3bc9bfb1c627e71aa 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 c15c1d0853..88bfe542d3 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 @@ -24,7 +24,13 @@ An earlier revision put it in a new `net/` package group, reasoning that dependi The plugin that revision shipped is gone with it. It let a composition declare the policy in `cordis.yml`, but no shipped bundle mounted it, so the launcher's path was the only reachable one — and its `Config` was the sole supplier of a configuration branch nothing else could reach. -**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. +**Four functions, because the call sites converged rather than the package growing an export each.** An earlier revision exported six: a dispatcher factory, a `node:http` agent factory, a proxy-URL lookup, a policy accessor, an installer, and a child-environment builder. Each existed for one SDK's transport, which is how a transport-policy package turns into a catalogue of other packages' constraints. Review asked whether the call sites could converge instead; they could, and each removal took a whole shape with it. The exporter moved to the SDK's `fetch` delegate, retiring the `node:http` factory. `web-fetch-http` builds its own pinning agent under an annotated exemption, retiring the dispatcher factory. E2B reads `route.proxy`, retiring the proxy-URL lookup. + +What remains is `installProxyFromEnvironment`, `proxyRouteFor`, `proxyEnvironmentForChild`, and `clearedProxyEnv` — one per way a caller can need the policy, none per SDK. Installation absorbed resolution and diagnostic reporting, which no caller needed apart: a resolved policy that is not installed routes nothing. + +`proxyRouteFor` also closes a defect the old accessor made expressible. `web-fetch-http` read the policy to decide whether to pin, then read it again to build a transport; an unmount between the two returned a direct, unpinned agent for a URL the first read had cleared as proxied. A route carries both, so the branch and the request cannot disagree. Its dispatcher is the process-wide one, closed rather than destroyed on disposal, so a request already in flight when a policy is unmounted still finishes. + +**The installed dispatcher routes by the policy, not by an environment it re-parses.** Installation 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 one reader only: a spawned child, which has no policy object to consult. 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. @@ -36,15 +42,19 @@ This keeps `proxyForUrl()` and the dispatcher answering from one set of values. 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 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. +**A spawned child gets the policy through its environment; a model-executing worker gets nothing.** `proxyEnvironmentForChild()` 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+. -**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`. +**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 were fixed by moving the call site onto a transport the dispatcher already covers rather than by giving this package a second export for each. + +The exporter now composes `OTLPExporterBase` with `createLegacyOtlpBrowserExportDelegate` — a published entry point of the same SDK package, and the one that posts through `fetch`. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes. + +Switching the exporter to `fetch` costs `compression`: gzip belongs to the SDK's Node transport, and a realistic OTLP batch measured 6.4x smaller with it. Nothing shipped enabled it, and telemetry that ignores the proxy simply fails inside a corporate network, so routing wins. What the exporter would silently ignore, the plugin now refuses at load — `exporter.compression`, `exporter.keepAlive`, and `exporter.httpAgentOptions` throw with the reason, so no deployment pays the difference without seeing it. In exchange the Node-version floor disappears: `proxyEnv` on an `http.Agent` needs 22.21 or 24.5, inside the engines range, so telemetry used to stay direct on 22.19, 22.20, and 24.0–24.4. **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` 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. +**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. `proxyRouteFor(url)` is the sanctioned replacement, and the one call site that genuinely owns its transport — `web-fetch-http`, pinning a request to addresses it validated — 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 @@ -76,12 +86,12 @@ The suite is hermetic against the developer's own environment: `plugin.spec.ts` ## Testing -`packages/util/http-proxy` holds 89 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and 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/util/http-proxy` holds 84 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and 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. Every case installs through `installProxyFromEnvironment`, so no test can assert a policy object a real launch could not produce. `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. +`verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `proxyRouteFor`, 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 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. +The egress suite carries the negative case for telemetry — a `node:http` request under the same installed policy reaches no proxy — so a return to the SDK's Node transport cannot quietly un-proxy it. Its positive case no longer branches on the runtime, because `fetch` reaches the dispatcher on every supported Node. 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 ee4b9b2768..f5afee9de4 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 @@ -24,7 +24,13 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 那次修订一并引入的插件也随之删除。它让某个组合可以把策略写进 `cordis.yml`,但没有任何随附 bundle 挂载它,因此启动器那条路径是唯一可达的——而它的 `Config` 是那条配置分支唯一的供给方,别处无从到达。 -**已安装的 dispatcher 按策略路由,而不是重新解析一遍环境。** `installGlobalProxy` 构造一个 `Agent`,其按 origin 调用的 `factory` 会询问 `proxyForUrl` 该 origin 的去向,并据此返回 `ProxyAgent` 或 undici 自带的默认客户端。undici 的 `EnvHttpProxyAgent` 曾是首选,但对这套策略是错的:没有 `HTTPS_PROXY` 时它会把 HTTPS agent 设为 HTTP agent,于是本包在拒绝某个 SOCKS 或畸形 URL 后本应保持直连的 scheme 仍会被隧道转发,而诊断却声称直连。让路由走同一个谓词,从构造上而非靠测试消除了这一类分歧。把策略发布到环境中的做法保留下来,但如今只服务那些拿不到策略对象的读者:Node 的 `proxyEnv` 选项,以及每个派生的子进程。 +**四个函数——收敛的是调用方,而不是让本包为每个 SDK 各加一个导出。** 早先一版导出六个:dispatcher 工厂、`node:http` agent 工厂、代理 URL 查询、策略访问器、安装器与子进程环境构造器。每一个都为某个 SDK 的传输而存在,而这正是一个传输策略包退化成「别的包的约束目录」的过程。Review 问能不能反过来让调用方收敛;能,而且每删掉一个导出都带走了一整种写法。导出器改用 SDK 的 `fetch` delegate,`node:http` agent 工厂随之退场。`web-fetch-http` 在带注释的豁免下自建 pin agent,dispatcher 工厂随之退场。E2B 读 `route.proxy`,代理 URL 查询随之退场。 + +剩下的是 `installProxyFromEnvironment`、`proxyRouteFor`、`proxyEnvironmentForChild` 与 `clearedProxyEnv`——按「调用方需要策略的方式」各一个,而不是按 SDK 各一个。安装吸收了解析与诊断上报,因为没有调用方需要把它们分开:解析出来却不安装的策略什么也路由不了。 + +`proxyRouteFor` 还堵掉了旧访问器让人写得出来的一个缺陷。`web-fetch-http` 先读策略决定是否 pin,再读一次去构造传输;两次读取之间发生卸载,就会为第一次读取已判定走代理的 URL 返回一个直连且未 pin 的 agent。路由把两者一起交出,分支与请求便无从分歧。它携带的是进程级 dispatcher,dispose 时是 close 而非 destroy,因此策略被卸载时已经发出的请求仍会跑完。 + +**已安装的 dispatcher 按策略路由,而不是重新解析一遍环境。** 安装过程构造一个 `Agent`,其按 origin 调用的 `factory` 会询问 `proxyForUrl` 该 origin 的去向,并据此返回 `ProxyAgent` 或 undici 自带的默认客户端。undici 的 `EnvHttpProxyAgent` 曾是首选,但对这套策略是错的:没有 `HTTPS_PROXY` 时它会把 HTTPS agent 设为 HTTP agent,于是本包在拒绝某个 SOCKS 或畸形 URL 后本应保持直连的 scheme 仍会被隧道转发,而诊断却声称直连。让路由走同一个谓词,从构造上而非靠测试消除了这一类分歧。把策略发布到环境中的做法保留下来,但如今只服务一类读者:派生的子进程——它没有策略对象可查。 这样 `proxyForUrl()` 与 dispatcher 就从同一组值给出答案。两者必须一致:一旦对某个 URL 产生分歧,`web-fetch-http` 就会把 dispatcher 本打算隧道转发的连接固定到某个地址上。 @@ -36,15 +42,19 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。 -**派生的子进程通过环境获得策略;执行模型代码的 worker 什么也不获得。** `childProxyEnv()` 并入 `scrubbedParentEnv()`——每个 spawner 本就共享的那一个函数。workflow worker **不**接收它:它执行的是模型编写的脚本体,而代理 URL 可能携带 `user:password`。这与 code runtime 保持的containment 相同,也是 `docs/defensive-patterns.md` 的要求,因此 workflow 自身的请求直连。 +**派生的子进程通过环境获得策略;执行模型代码的 worker 什么也不获得。** `proxyEnvironmentForChild()` 并入 `scrubbedParentEnv()`——每个 spawner 本就共享的那一个函数。workflow worker **不**接收它:它执行的是模型编写的脚本体,而代理 URL 可能携带 `user:password`。这与 code runtime 保持的隔离相同,也是 `docs/defensive-patterns.md` 的要求,因此 workflow 自身的请求直连。 这接受了一处已记录的接缝。此类上下文按 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`。 +**有两个 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。两者都实测为直连,而修复方式不是给本包各加一个导出,而是把调用点搬到 dispatcher 本就覆盖的传输上。 + +导出器改为用 `OTLPExporterBase` 组合 `createLegacyOtlpBrowserExportDelegate`——同一个 SDK 包的公开入口,也是通过 `fetch` 投递的那一个。E2B 则接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。 + +把导出器换到 `fetch` 的代价是 `compression`:gzip 属于该 SDK 的 Node 传输,实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。目前没有任何随附配置启用它,而在企业代理网络里,不遵循代理的遥测干脆发不出去,因此路由优先。导出器本会静默忽略的选项,现在由插件在加载期拒绝——`exporter.compression`、`exporter.keepAlive` 与 `exporter.httpAgentOptions` 会带着原因抛错,任何部署都不会在看不见的情况下承担这个差价。换来的是 Node 版本下限消失:`http.Agent` 的 `proxyEnv` 需要 22.21 或 24.5,而这落在 engines 范围之内,因此遥测过去在 22.19、22.20 与 24.0–24.4 上一直是直连。 **每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、遥测、E2B、派生的子 Node 与 worker 线程。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。 -**用门禁防止该缺陷复现。** `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` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。 +**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;唯一一处确实自有传输的调用点——`web-fetch-http`,它把请求钉在已校验的地址上——用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。 ## Alternatives considered @@ -76,12 +86,12 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l ## Testing -`packages/util/http-proxy` 有 89 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。 +`packages/util/http-proxy` 有 84 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。所有用例一律经 `installProxyFromEnvironment` 安装,因此没有测试能断言一次真实启动无法产生的策略对象。 `packages/web/web-fetch-http/tests/proxy.spec.ts` 断言了最关键的那个决定:经由代理时公网地址解析器完全不被调用,而被绕过的一跳仍恰好调用一次,且跨域重定向拒绝在代理路径上依然成立。 -`verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `createDispatcher`、接受带注释的豁免,并在当前代码树上通过。 +`verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。 -出网测试为遥测保留了负向用例——恢复 SDK 自带的默认 agent 就触及不到代理——因此升级无法悄悄把它变回直连。其正向用例按运行时分支,因为导出器的 agent 需要 Node 22.21+ 或 24.5+。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。 +出网测试为遥测保留了负向用例——在同一份已安装策略下发一个 `node:http` 请求,触及不到代理——因此改回 SDK 的 Node 传输无法悄悄把遥测变回直连。其正向用例不再按运行时分支,因为在所有受支持的 Node 上 `fetch` 都会落到 dispatcher。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。 无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 659018f35d..1e86e8393f 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -48,8 +48,8 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | -| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index dbac4eeed0..1b12218ab5 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -30,7 +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 { installProxyFromEnvironment } 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' @@ -212,11 +212,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // 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 disposeProxy = await installProxyFromEnvironment( + options.environment, + (message) => { process.stderr.write(`${NAME}: ${message}\n`) }, + ) const composed = await composeProfile(options.profile, options.patchFiles) const app: { current?: Context } = {} diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index e59234842b..f6163f21ad 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: a8fa6d46fe1b3c7cedf2d465eb539587a12186af -config-catalog.zh.md: 0523bc6ac0d53b802907abd3c81037f960092ec9 +config-catalog.md: f53dbb96e7e91f4c8c1d32dbe86f1ec13037f982 +config-catalog.zh.md: 8587d0171c0bd9bf0f6a9c8a727b77947606ce48 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a8fa6d46fe..f53dbb96e7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1967,11 +1967,15 @@ export interface Config { mode?: SessionTelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete - * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, - * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` - * is the one field this package requires and validates itself. + * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`, + * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the + * one field this package requires and validates itself. + * + * The transport is the SDK's `fetch` one, so the three options that exist + * only for its `node:http` transport — `compression`, `keepAlive`, and + * `httpAgentOptions` — are refused at load rather than ignored. */ - exporter?: OTLPExporterNodeConfigBase & { + exporter?: OTLPExporterConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string } @@ -1992,9 +1996,9 @@ export enum SessionTelemetryMode { } ``` -Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:92`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 0523bc6ac0..8587d0171c 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1969,11 +1969,15 @@ export interface Config { mode?: SessionTelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete - * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, - * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` - * is the one field this package requires and validates itself. + * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`, + * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the + * one field this package requires and validates itself. + * + * The transport is the SDK's `fetch` one, so the three options that exist + * only for its `node:http` transport — `compression`, `keepAlive`, and + * `httpAgentOptions` — are refused at load rather than ignored. */ - exporter?: OTLPExporterNodeConfigBase & { + exporter?: OTLPExporterConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string } @@ -1994,9 +1998,9 @@ export enum SessionTelemetryMode { } ``` -依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`) +依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterConfigBase`(`@opentelemetry/otlp-exporter-base`) -来源:[`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts) +来源:[`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts) diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml index fe244ec86d..20d892bae1 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: 22db4a583771ac730217a95a9e5662ef6516c7fd -network-proxy.zh.md: a9479a582075327b35998491543e9d73056db0b5 +network-proxy.md: 3561ec5b0dfc4290ab29dfe66fc91b19031fa31d +network-proxy.zh.md: 928f67db215650f2761ae5c929de3605b76b2520 diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md index 22db4a5837..3561ec5b0d 100644 --- a/docs/user/guide/network-proxy.md +++ b/docs/user/guide/network-proxy.md @@ -67,7 +67,6 @@ Not every request DSH makes goes through the proxy: - **Anything on this machine.** Loopback is always direct: `localhost`, the whole `127.0.0.0/8` range, `::1`, and `0.0.0.0`. A proxy cannot usefully reach a service that only listens locally. - **Code the model writes.** The workflow and code-runtime workers never receive the proxy settings, so a script the model authors cannot read a proxy URL that may carry a password. Such a script reaches the network only if it configures that itself. -- **Telemetry on an older Node.** The OTLP exporter uses Node's own HTTP client, which learned to honor these variables in Node 22.21 and 24.5. On 22.19, 22.20, and 24.0–24.4 telemetry connects directly. - **`web_fetch` to a literal private address.** A URL naming an address like `http://10.0.0.5/` is refused rather than handed to the proxy, the same refusal it gets with no proxy configured. ## Check that it worked diff --git a/docs/user/guide/network-proxy.zh.md b/docs/user/guide/network-proxy.zh.md index a9479a5820..928f67db21 100644 --- a/docs/user/guide/network-proxy.zh.md +++ b/docs/user/guide/network-proxy.zh.md @@ -67,7 +67,6 @@ Node 只在进程启动时读取该变量,所以要在运行 `dsh` 之前导 - **本机上的一切。** loopback 始终直连:`localhost`、整个 `127.0.0.0/8` 段、`::1` 与 `0.0.0.0`。代理无法有意义地访问一个只在本地监听的服务。 - **模型编写的代码。** workflow 与 code-runtime worker 从不接收代理配置,因此模型编写的脚本读不到可能携带密码的代理 URL。这类脚本只有自行配置才能联网。 -- **较旧 Node 上的遥测。** OTLP 导出器使用 Node 自带的 HTTP 客户端,而它从 Node 22.21 与 24.5 起才遵循这些变量。在 22.19、22.20 与 24.0–24.4 上遥测直连。 - **`web_fetch` 访问字面量私网地址。** 形如 `http://10.0.0.5/` 的 URL 会被拒绝而非交给代理,与未配置代理时得到的拒绝相同。 ## 验证是否生效 diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index ac8303da4c..4333fdc431 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -9,7 +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' +import { proxyRouteFor } from '@deepseek-ai/dsh-http-proxy' import { e2bApiUrl } from './api-url.ts' export { @@ -156,13 +156,13 @@ export class E2BRuntime extends Service { // 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 route = proxyRouteFor(new URL(e2bApiUrl())) const sandbox = await Sandbox.create({ apiKey: this.config.apiKey, timeoutMs: this.config.timeoutMs, secure: true, lifecycle: { onTimeout: 'kill' }, - ...proxy === undefined ? {} : { proxy }, + ...route.proxied ? { proxy: route.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 index db25ba275b..0bbaca9aa7 100644 --- a/packages/e2b/e2b/tests/egress.spec.ts +++ b/packages/e2b/e2b/tests/egress.spec.ts @@ -1,7 +1,7 @@ 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' +import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' let seen: string[] = [] let proxy: Server @@ -21,12 +21,13 @@ beforeAll(async () => { }) afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +/** The launch environment of a user who exported one proxy for both schemes. */ +function proxyEnv(): { get(name: string): { value: string } | undefined } { + return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } } async function observe(run: () => Promise): Promise { seen = [] - const dispose = await installGlobalProxy(policy()) + const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) try { await run().catch(() => undefined) } finally { await dispose() } return seen } @@ -57,13 +58,18 @@ describe('e2b control-plane URL', () => { it('keeps the loopback debug plane direct instead of sending its API key to a proxy', async () => { const { e2bApiUrl } = await import('../src/api-url.ts') - const { proxyForUrl, resolveProxyPolicy } = await import('@deepseek-ai/dsh-http-proxy') + const { proxyRouteFor } = 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( + // A real launch installs from the environment, and the resolved policy always bypasses loopback. + const dispose = await installProxyFromEnvironment( createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]), + () => undefined, ) - expect(proxyForUrl(resolved, new URL(e2bApiUrl({ E2B_DEBUG: 'true' })))).toBeUndefined() - expect(proxyForUrl(resolved, new URL(e2bApiUrl({})))).toBe(proxyUrl) + try { + expect(proxyRouteFor(new URL(e2bApiUrl({ E2B_DEBUG: 'true' })))).toEqual({ proxied: false }) + expect(proxyRouteFor(new URL(e2bApiUrl({})))).toMatchObject({ proxied: true, proxy: proxyUrl }) + } finally { + await dispose() + } }) }) diff --git a/packages/llm/llm-deepseek/tests/egress.spec.ts b/packages/llm/llm-deepseek/tests/egress.spec.ts index 02032d3098..46a3ca5d77 100644 --- a/packages/llm/llm-deepseek/tests/egress.spec.ts +++ b/packages/llm/llm-deepseek/tests/egress.spec.ts @@ -1,7 +1,7 @@ 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' +import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' let seen: string[] = [] let proxy: Server @@ -21,12 +21,13 @@ beforeAll(async () => { }) afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +/** The launch environment of a user who exported one proxy for both schemes. */ +function proxyEnv(): { get(name: string): { value: string } | undefined } { + return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } } async function observe(run: () => Promise): Promise { seen = [] - const dispose = await installGlobalProxy(policy()) + const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) try { await run().catch(() => undefined) } finally { await dispose() } return seen } diff --git a/packages/llm/llm-pi-ai/tests/egress.spec.ts b/packages/llm/llm-pi-ai/tests/egress.spec.ts index 15283a89ca..ba9c38c651 100644 --- a/packages/llm/llm-pi-ai/tests/egress.spec.ts +++ b/packages/llm/llm-pi-ai/tests/egress.spec.ts @@ -1,7 +1,7 @@ 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' +import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' let seen: string[] = [] let proxy: Server @@ -21,12 +21,13 @@ beforeAll(async () => { }) afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +/** The launch environment of a user who exported one proxy for both schemes. */ +function proxyEnv(): { get(name: string): { value: string } | undefined } { + return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } } async function observe(run: () => Promise): Promise { seen = [] - const dispose = await installGlobalProxy(policy()) + const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) try { await run().catch(() => undefined) } finally { await dispose() } return seen } diff --git a/packages/mcp/mcp-client/tests/egress.spec.ts b/packages/mcp/mcp-client/tests/egress.spec.ts index df0894a4b1..02f8e6b7e2 100644 --- a/packages/mcp/mcp-client/tests/egress.spec.ts +++ b/packages/mcp/mcp-client/tests/egress.spec.ts @@ -1,7 +1,7 @@ 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' +import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' let seen: string[] = [] let proxy: Server @@ -21,12 +21,13 @@ beforeAll(async () => { }) afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +/** The launch environment of a user who exported one proxy for both schemes. */ +function proxyEnv(): { get(name: string): { value: string } | undefined } { + return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } } async function observe(run: () => Promise): Promise { seen = [] - const dispose = await installGlobalProxy(policy()) + const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) try { await run().catch(() => undefined) } finally { await dispose() } return seen } diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 7f2f06a87d..65e698b411 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -29,11 +29,11 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", - "@opentelemetry/exporter-logs-otlp-http": "^0.220.0", "@opentelemetry/otlp-exporter-base": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", - "@deepseek-ai/schemastery": "workspace:^" + "@deepseek-ai/schemastery": "workspace:^", + "@opentelemetry/otlp-transformer": "^0.220.0" }, "peerDependencies": { "@deepseek-ai/dsh-command-feedback": "workspace:^", diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 18e60154bf..b9d5e5477b 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -31,9 +31,11 @@ import { LoggerProvider, 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 { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base' +import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http' +import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer' +import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base' +import type { ReadableLogRecord } from '@opentelemetry/sdk-logs' import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' import { resourceFromAttributes } from '@opentelemetry/resources' @@ -94,11 +96,15 @@ export interface Config { mode?: SessionTelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete - * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, - * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` - * is the one field this package requires and validates itself. + * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`, + * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the + * one field this package requires and validates itself. + * + * The transport is the SDK's `fetch` one, so the three options that exist + * only for its `node:http` transport — `compression`, `keepAlive`, and + * `httpAgentOptions` — are refused at load rather than ignored. */ - exporter?: OTLPExporterNodeConfigBase & { + exporter?: OTLPExporterConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string } @@ -132,6 +138,12 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000 // protocol limit, not a deployment default. const MAX_TIMER_DELAY_MILLIS = 2_147_483_647 +/** + * Exporter options the SDK defines only for its `node:http` transport. They reach the `fetch` + * transport this package uses, which silently ignores every one of them. + */ +const NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS = ['compression', 'keepAlive', 'httpAgentOptions'] as const + /** Severity mapping from the Service Definition's three-level vocabulary to OTel severity numbers. */ const SEVERITY: Record = { info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' }, @@ -168,7 +180,8 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { return } - const url = config.exporter?.url + const exporter = config.exporter ?? {} + const url = exporter.url if (url === undefined || url.length === 0) { throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)') } @@ -182,6 +195,13 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`) } + // Options that exist only for the SDK's `node:http` transport, which this package no longer + // uses. The exporter would accept and ignore each one, so a deployment that asked for gzip + // would quietly send uncompressed batches; refusing at load is what makes the change visible. + const nodeOnly = NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS.filter(name => name in exporter) + if (nodeOnly.length > 0) { + throw new Error(`session-telemetry-otel: exporter.${nodeOnly.join(', exporter.')} not supported: telemetry is exported through fetch so a configured proxy carries it, and the node:http transport those options belong to would need an http.Agent this package no longer builds`) + } // The one processor field checked beyond the SDK's own validation: the // SDK accepts a non-positive batch size, but its shutdown drain then // splices empty batches without consuming the queue — dispose would hang @@ -214,16 +234,28 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { // (service.name/version); the transport-level user-agent is the // SDK's own, per the axiom. // - // 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; 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: config.exporter?.keepAlive ?? true }), - ...config.exporter, - }), + // The delegate is the SDK's `fetch` one rather than its Node `node:http` one. Both are + // published entry points of the same package; the `fetch` transport reaches undici's + // global dispatcher, so a configured proxy carries telemetry with no proxy-aware code + // here and with no Node-version floor. The Node transport would need an `http.Agent`, + // and Node only learned to route one from the environment in 22.21 and 24.5. + // + // What that costs: `compression` is a Node-transport option and has no effect here. + // + // The delegate is deprecated in favour of `createOtlpFetchExportDelegate`, which the SDK + // exports from no public subpath at 0.220 — this legacy wrapper is the only supported way + // to reach it, and does nothing but call it. Composing the public + // `createOtlpNetworkExportDelegate` instead would mean owning the fetch transport and its + // retry wrapper, both SDK-internal. + exporter: new OTLPExporterBase( + // oxlint-disable-next-line typescript/no-deprecated -- the SDK exports its replacement from no public subpath at 0.220. + createLegacyOtlpBrowserExportDelegate( + exporter, + JsonLogsSerializer, + 'v1/logs', + { 'Content-Type': 'application/json' }, + ), + ), }), ], }) diff --git a/packages/session/session-telemetry-otel/tests/egress.spec.ts b/packages/session/session-telemetry-otel/tests/egress.spec.ts index df43b8873e..da8342f125 100644 --- a/packages/session/session-telemetry-otel/tests/egress.spec.ts +++ b/packages/session/session-telemetry-otel/tests/egress.spec.ts @@ -1,7 +1,7 @@ -import { createServer, type Server } from 'node:http' +import http, { 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' +import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' let seen: string[] = [] let proxy: Server @@ -21,12 +21,13 @@ beforeAll(async () => { }) afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +/** The launch environment of a user who exported one proxy for both schemes. */ +function proxyEnv(): { get(name: string): { value: string } | undefined } { + return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } } async function observe(run: () => Promise): Promise { seen = [] - const dispose = await installGlobalProxy(policy()) + const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) try { await run().catch(() => undefined) } finally { await dispose() } return seen } @@ -65,61 +66,24 @@ async function exportThroughBackend(host: string, exporter: Record= 5) || major > 24 || (major === 22 && minor >= 21) -} - describe('session-telemetry-otel egress', () => { it('exports through the proxy', async () => { 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('') + // No runtime gate: the exporter posts through `fetch`, which resolves undici's global + // dispatcher on every Node this repository supports. The SDK's own `node:http` transport would + // have needed `http.Agent`'s `proxyEnv`, which arrived in 22.21 and 24.5 — inside the engines + // range, so telemetry would have stayed direct on 22.19, 22.20, and 24.0–24.4. + expect(observed).toContain('otel-proxied.invalid') }) - 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 }) - }, + it('reaches no proxy over node:http — the transport this exporter no longer uses', async () => { + const observed = await observe(() => new Promise((resolve) => { + // The mechanism behind the case above, asserted rather than described: a global dispatcher is + // undici's, and `node:http` never consults it. An exporter built on the SDK's Node transport + // would take this path and leave telemetry direct however the proxy is configured. + http.get('http://otel-direct.invalid/v1/logs', (response) => { response.resume(); resolve() }) + .on('error', () => { resolve() }) })) - // 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/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 822203abb8..30e50ec0d5 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -237,24 +237,37 @@ describe('OpenTelemetrySessionBackend wire', () => { const { url, captures } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) - // `compression` is a documented SDK exporter option; the advertised - // verbatim passthrough must hand it (and every other field) to the - // exporter rather than silently rebuilding url/headers only. + // `headers` is a documented SDK exporter option this package neither reads nor rebuilds; the + // advertised verbatim passthrough must hand it (and every other field) to the exporter rather + // than silently rebuilding url only. const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, - exporter: { url, compression: 'gzip' }, - } as Config) - const session = ctx.sessions.create(SessionId('gzip'), { meta: {} }) + exporter: { url, headers: { 'x-probe': 'passthrough' } }, + }) + const session = ctx.sessions.create(SessionId('passthrough'), { meta: {} }) session.append('turn/start', { turn: 1 }) await fiber.dispose() expect(captures.length).toBeGreaterThan(0) - expect(captures[0]!.headers['content-encoding']).toBe('gzip') + expect(captures[0]!.headers['x-probe']).toBe('passthrough') const types = allRecords(captures).flatMap(({ record }) => record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? []) expect(types).toContain('turn/start') }) + it('refuses an exporter option that belongs to the node:http transport', async () => { + const { url } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + // Telemetry goes through `fetch` so a configured proxy carries it, and that transport ignores + // `compression`. Accepting the option would send uncompressed batches while the configuration + // said gzip; the deployment has to see the trade rather than pay it silently. + await expect(ctx.plugin(OpenTelemetrySessionBackend, { + mode: SessionTelemetryMode.FULL, + exporter: { url, compression: 'gzip' }, + } as unknown as Config)).rejects.toThrow(/exporter\.compression not supported/) + }) + it('maps warn severity from record policy and leaves the seam flush hint unimplemented', async () => { const { url, captures } = await mockCollector() const { ctx, fiber } = await boot(url) diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index c028d2631e..d31b3685e4 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -9,7 +9,7 @@ */ import { Context, Service } from '@deepseek-ai/cordis' -import { childProxyEnv } from '@deepseek-ai/dsh-http-proxy' +import { proxyEnvironmentForChild } 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' @@ -70,7 +70,7 @@ export function scrubbedParentEnv(): Record { // 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())) { + for (const [name, value] of Object.entries(proxyEnvironmentForChild())) { if (value === undefined) Reflect.deleteProperty(env, name) else env[name] = value } diff --git a/packages/subprocess/subprocess/tests/egress.spec.ts b/packages/subprocess/subprocess/tests/egress.spec.ts index b20b6f961d..9c1fd2692b 100644 --- a/packages/subprocess/subprocess/tests/egress.spec.ts +++ b/packages/subprocess/subprocess/tests/egress.spec.ts @@ -2,11 +2,7 @@ import { createServer, type Server } from 'node:http' 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 { clearedProxyEnv, installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '../src/index.ts' @@ -49,8 +45,9 @@ afterAll(async () => { 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) + const names = Object.keys(clearedProxyEnv()) + saved = Object.fromEntries(names.map(name => [name, process.env[name]])) + for (const name of names) Reflect.deleteProperty(process.env, name) }) afterEach(() => { @@ -78,10 +75,10 @@ describe('child process egress', () => { 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( + const dispose = await installProxyFromEnvironment( createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]), + () => undefined, ) - const dispose = await installGlobalProxy(policy) let childEnv: Record = {} try { childEnv = scrubbedParentEnv() @@ -99,10 +96,10 @@ describe('child process egress', () => { it('a child Node reaches a proxy the user gave only as ALL_PROXY', async () => { process.env.ALL_PROXY = proxyUrl - const { policy } = resolveProxyPolicy( + const dispose = await installProxyFromEnvironment( createLaunchEnvironmentSnapshot([{ source: 'process', values: { ALL_PROXY: proxyUrl } }]), + () => undefined, ) - const dispose = await installGlobalProxy(policy) let childEnv: Record = {} try { childEnv = scrubbedParentEnv() @@ -122,21 +119,22 @@ describe('child process egress', () => { // 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( + const dispose = await installProxyFromEnvironment( createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' }, }]), + () => undefined, ) - 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) + // The bypass list is always the resolved one; the user set none, so it is the loopback + // entries alone — without them the child sends its own localhost traffic to the proxy. + expect(child.NO_PROXY).toBe('localhost,127.0.0.1,::1,[::1]') } finally { await dispose() } @@ -144,10 +142,10 @@ describe('child process egress', () => { 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( + const dispose = await installProxyFromEnvironment( createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]), + () => undefined, ) - const dispose = await installGlobalProxy(policy) try { // 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 diff --git a/packages/test-support/loader-smoke/src/index.ts b/packages/test-support/loader-smoke/src/index.ts index 7ce77c79a7..100bdca67a 100644 --- a/packages/test-support/loader-smoke/src/index.ts +++ b/packages/test-support/loader-smoke/src/index.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-loader-smoke */ -import { PROXY_ENV_NAMES } from '@deepseek-ai/dsh-http-proxy' +import { clearedProxyEnv } from '@deepseek-ai/dsh-http-proxy' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -31,14 +31,6 @@ 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' diff --git a/packages/test-support/session-snapshot/src/harness.ts b/packages/test-support/session-snapshot/src/harness.ts index 78a3a6e0a9..f1874a77d1 100644 --- a/packages/test-support/session-snapshot/src/harness.ts +++ b/packages/test-support/session-snapshot/src/harness.ts @@ -35,17 +35,9 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from './launcher.ts' -import { PROXY_ENV_NAMES } from '@deepseek-ai/dsh-http-proxy' +import { clearedProxyEnv } 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 diff --git a/packages/util/http-proxy/README.i18n.yaml b/packages/util/http-proxy/README.i18n.yaml index 124db4adfe..f496bbafc0 100644 --- a/packages/util/http-proxy/README.i18n.yaml +++ b/packages/util/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/util/http-proxy/README.md -README.md: 1d27fc936c1921eb5b6e358cc4341fdfa5b5b52c -README.zh.md: 7aad74ae76907aad8dc8d5f8716dd84a5e48a9de +README.md: 2bad73fa7ab670d73adab6e9a82602b0fa374752 +README.zh.md: 85f49c2e33d2064f49acba9df2c935d81c36b175 diff --git a/packages/util/http-proxy/README.md b/packages/util/http-proxy/README.md index 1d27fc936c..2bad73fa7a 100644 --- a/packages/util/http-proxy/README.md +++ b/packages/util/http-proxy/README.md @@ -9,7 +9,7 @@ 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. +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. Four functions cover everything the global dispatcher cannot reach on its own — install the policy, ask where one request goes, hand the policy to a spawned child, and strip it for a replay. ## Table of Contents @@ -33,12 +33,17 @@ Plain `fetch()` is proxied, and so is any SDK that reaches `globalThis.fetch` | 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 spawn whose environment you build yourself | apply `childProxyEnv()` to it (`undefined` means remove) | +| A plain request, or an SDK that reaches `globalThis.fetch` | nothing — the global dispatcher already routes it | +| A call that must branch on whether this request is proxied | `proxyRouteFor(url)` | +| An SDK that takes a proxy URL of its own | `proxyRouteFor(url)`, and pass `route.proxy` | +| A spawn whose environment you build yourself | apply `proxyEnvironmentForChild()` to it (`undefined` means remove) | +| A harness that must reach its own fixture server | apply `clearedProxyEnv()` to the spawn | -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. +`proxyRouteFor` answers with the transport that answer assumed, not just the answer: its proxied arm carries the dispatcher already routing by this policy. A caller that read the policy and then built its own transport could have an unmount land between the two and send the request somewhere its branch never cleared. + +An SDK that builds its own transport reaches none of this. The two this repository ships that did — the OTLP exporter and the E2B SDK — were changed to a transport that does: the exporter now posts through `fetch`, and E2B is handed `route.proxy`. + +Constructing `new Agent(...)` and passing it as `dispatcher` overrides the global one and silently bypasses the proxy. `verify-no-bare-dispatcher` rejects that outside this package. One call site legitimately owns its transport — `web-fetch-http` pins a request to addresses it validated, which is per-request state a process-wide dispatcher cannot hold — and says so with a `proxy-exempt:` comment on the line. 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. @@ -68,8 +73,8 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri | 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` | The package face: six functions and the types they use. | +| `src/install.ts` | The global dispatcher, the active-policy record, the route, and the child environment. Imports undici dynamically. | +| `src/index.ts` | The package face: four functions and one type. | ### Bypass matching @@ -103,7 +108,7 @@ These limits define when the package is a poor fit. They are current package con - **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment. A macOS or Windows system-proxy setting is not read, so a user who only toggled it in a proxy application must still export the variables; a SOCKS URL is reported and that scheme stays direct rather than borrowing another scheme's proxy. - **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 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 spawned child honors the policy only on a new enough runtime** — it reads the published environment through Node's `NODE_USE_ENV_PROXY` (22.21+, 24+), and the engines range admits 22.19 and 22.20, where such a child stays direct. A child also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in their separators and IPv4-range support. Nothing in this process depends on a Node version: every in-process request reaches the global dispatcher. - **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`. diff --git a/packages/util/http-proxy/README.zh.md b/packages/util/http-proxy/README.zh.md index 7aad74ae76..85f49c2e33 100644 --- a/packages/util/http-proxy/README.zh.md +++ b/packages/util/http-proxy/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代理后面运行的 Harness 无论用户导出了什么都会直连——LLM(大语言模型)请求、每次 web 搜索、走 HTTP 的 MCP、遥测与沙箱 SDK 一概如此。本包从启动器的环境快照解析出一份代理策略,并把它装成 undici 的全局 dispatcher,而这正是 `fetch` 解析的对象。因此普通调用点无需改动、也无需引入本包:写 `fetch()` 就已经走代理。本包还负责全局 dispatcher 覆盖不到的三处——需要自定义 agent 选项的调用方、拥有独立 `globalThis` 的 worker 线程、以及派生出的子 Node 进程——并为每一处给出唯一受支持的走法。 +Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代理后面运行的 Harness 无论用户导出了什么都会直连——LLM(大语言模型)请求、每次 web 搜索、走 HTTP 的 MCP、遥测与沙箱 SDK 一概如此。本包从启动器的环境快照解析出一份代理策略,并把它装成 undici 的全局 dispatcher,而这正是 `fetch` 解析的对象。因此普通调用点无需改动、也无需引入本包:写 `fetch()` 就已经走代理。全局 dispatcher 自身够不到的场合由四个函数覆盖——安装策略、询问某个请求怎么发、把策略交给派生的子进程、以及为重放清掉它。 ## 目录 @@ -29,16 +29,21 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代 ### 编写新的出站调用 -普通 `fetch()` 已经走代理,任何最终落到 `globalThis.fetch` 的 SDK 也一样——MCP HTTP 传输与 pi-ai 提供方栈都是如此。但自建传输的 SDK **不会**,而本仓库随附的 SDK 里就有两个属于此类:OTLP 导出器通过 `node:http` 投递,E2B SDK 自建 undici dispatcher。不要对任何 SDK 想当然,去查。 +普通 `fetch()` 已经走代理,任何最终落到 `globalThis.fetch` 的 SDK 也一样——MCP HTTP 传输与 pi-ai 提供方栈都是如此。不要对任何 SDK 想当然,去查。 | 你要写的东西 | 使用 | |---|---| -| 需要自定义 agent 选项的调用(连接池、超时、DNS 查询) | `createDispatcher(url, options)` | -| 接受 `node:http` agent 的 SDK | `createNodeHttpAgent(protocol, options)` | -| 接受自有代理 URL 的 SDK | `proxyUrlFor(url)` | -| 由你自己构造环境的派生进程 | 把 `childProxyEnv()` 应用到它上面(`undefined` 表示删除) | +| 普通请求,或最终落到 `globalThis.fetch` 的 SDK | 什么都不用——全局 dispatcher 已经在路由它 | +| 需要按“这次请求是否走代理”分支的调用 | `proxyRouteFor(url)` | +| 接受自有代理 URL 的 SDK | `proxyRouteFor(url)`,把 `route.proxy` 传进去 | +| 由你自己构造环境的派生进程 | 把 `proxyEnvironmentForChild()` 应用到它上面(`undefined` 表示删除) | +| 必须连到自带 fixture 服务器的测试框架 | 把 `clearedProxyEnv()` 应用到该派生进程 | -构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法;确实必须忽略代理的行用 `proxy-exempt:` 注释说明理由。 +`proxyRouteFor` 给出的不只是答案,还有该答案所假定的传输:走代理的那一支携带着此刻正按该策略路由的 dispatcher。若调用方先读策略、再自建传输,卸载就可能落在两次读取之间,把请求发往其分支从未放行的去处。 + +自建传输的 SDK 接触不到上述任何一条。本仓库随附的两个此类 SDK 都已改到能被覆盖的传输上:OTLP 导出器改为通过 `fetch` 投递,E2B 则接收 `route.proxy`。 + +构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法。有一处调用点确实自有传输——`web-fetch-http` 会把请求钉在它已校验过的地址上,而这是进程级 dispatcher 无法承载的单次请求状态——它在该行用 `proxy-exempt:` 注释说明。 该门禁看不进 SDK 内部,因此仓库中每一个出网点都另有一份 `egress.spec.ts`:它驱动该点的真实代码路径穿过一个假代理,并断言代理确实收到了请求。新增出网点就补一份。它是唯一能发现 SDK 在我们脚下更换传输的手段——OTLP 与 E2B 这两个漏洞正是这样被发现的。 @@ -68,8 +73,8 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` | 文件 | 承载 | |---|---| | `src/policy.ts` | 解析、绕过匹配与脱敏。不引入任何传输实现,因此在没有 undici 的环境中仍可加载。 | -| `src/install.ts` | 全局 dispatcher、生效策略记录、`createDispatcher` 与 `childProxyEnv`。动态引入 undici。 | -| `src/index.ts` | 本包的对外面:六个函数及其使用的类型。 | +| `src/install.ts` | 全局 dispatcher、生效策略记录、路由与子进程环境。动态引入 undici。 | +| `src/index.ts` | 本包的对外面:四个函数与一个类型。 | ### 绕过匹配 @@ -103,7 +108,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` - **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告,且该协议保持直连,不会借用另一协议的代理。 - **不支持自定义证书颁发机构**——做 TLS 拦截的企业代理需要在启动前为进程设置 `NODE_EXTRA_CA_CERTS`,本包既不设置也不校验它。 -- **独立的 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 区间处理与本包不同。 +- **派生的子进程只在足够新的运行时上遵循策略**——它通过 Node 的 `NODE_USE_ENV_PROXY` 读取已发布的环境(22.21+、24+),而 engines 范围允许 22.19 与 22.20,在这两个版本上这样的子进程保持直连。子进程还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。本进程内不依赖任何 Node 版本:每一次进程内请求都会落到全局 dispatcher。 - **执行模型编写代码的 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/util/http-proxy/src/index.ts b/packages/util/http-proxy/src/index.ts index 5b40b55bd9..f12de6f093 100644 --- a/packages/util/http-proxy/src/index.ts +++ b/packages/util/http-proxy/src/index.ts @@ -2,36 +2,22 @@ * 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 library resolves one policy from the launch + * directly no matter what the user exported. The launcher 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. + * LLM adapters, web search, MCP over HTTP, and telemetry are covered without touching their code. * - * The launcher resolves and installs once, before the first plugin mounts. This is a library, not a - * plugin: transport policy has one answer per process, so there is nothing for a composition to - * mount, swap, or scope. + * This is a library, not a plugin: transport policy has one answer per process, so there is nothing + * for a composition to mount, swap, or scope. * - * Six exports, one per way a caller can need the policy: resolve it, install it, get a dispatcher - * for a request, get a `node:http` agent for an SDK that takes one, get a proxy URL for an SDK that - * takes that, and get the environment a spawned child needs. + * Four functions, one per way a caller needs the policy — install it, ask how to send one request, + * build a child's environment, and strip the ambient one for a replay. * @module @deepseek-ai/dsh-http-proxy */ export { - proxyForUrl, - resolveProxyPolicy, - PROXY_ENV_NAMES, - type EnvLookup, - type ProxyDiagnostic, - type ProxyPolicy, - type ProxyResolution, -} from './policy.ts' - -export { - childProxyEnv, - createDispatcher, - createNodeHttpAgent, - currentProxyPolicy, - installGlobalProxy, - proxyUrlFor, + clearedProxyEnv, + installProxyFromEnvironment, + proxyEnvironmentForChild, + proxyRouteFor, + type ProxyRoute, } from './install.ts' diff --git a/packages/util/http-proxy/src/install.ts b/packages/util/http-proxy/src/install.ts index 0016390cea..d7035a12f7 100644 --- a/packages/util/http-proxy/src/install.ts +++ b/packages/util/http-proxy/src/install.ts @@ -1,15 +1,21 @@ /** - * 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. + * Proxy installation: the transport half of this package. It owns undici's global dispatcher and the + * process-wide record of which policy is active. * * `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, Pool } from 'undici' -import { DIRECT_POLICY, POLICY_ENV_NAMES, proxyForUrl, type ProxyPolicy } from './policy.ts' +import type { Dispatcher, Pool } from 'undici' +import { + POLICY_ENV_NAMES, + PROXY_ENV_NAMES, + proxyForUrl, + resolveProxyPolicy, + type EnvLookup, + type ProxyPolicy, +} from './policy.ts' /** The active policy, or `undefined` until one is installed. Process-wide, like the dispatcher it tracks. */ @@ -22,21 +28,42 @@ let active: ProxyPolicy | undefined * 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 + * {@link proxyEnvironmentForChild} 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 +/** The dispatcher installed with {@link active}, so a route can hand back the one already routing. */ +let installed: Dispatcher | undefined + /** - * The policy governing this process's outbound requests. + * How this process must send one request. * - * A caller that branches on the answer must hold this value and pass it back to - * {@link createDispatcher}: reading it twice lets an install or disposal land between the two reads. - * - * @returns the installed policy, or the direct one when {@link installGlobalProxy} has not run. + * A caller that branches on the answer needs the transport that answer assumed, or an install or + * disposal landing between the two would send the request somewhere the branch did not clear. The + * proxied arm therefore carries the dispatcher already routing by this policy: it is process-wide + * and long-lived, so a caller uses it and never closes it. Disposal closes that dispatcher rather + * than destroying it, so a request already dispatched when a policy is unmounted still finishes. */ -export function currentProxyPolicy(): ProxyPolicy { - return active ?? DIRECT_POLICY +export type ProxyRoute = + | { readonly proxied: true; readonly proxy: string; readonly dispatcher: Dispatcher } + | { readonly proxied: false } + +/** A route that sends nothing through a proxy, shared because it carries no per-request state. */ +const DIRECT_ROUTE: ProxyRoute = { proxied: false } + +/** + * Decide how to send one request, and hand back the transport that decision assumed. + * + * @param url - the request URL. + * @returns the proxied route with its proxy URL and dispatcher, or the direct route. + */ +export function proxyRouteFor(url: URL): ProxyRoute { + const policy = active + const dispatcher = installed + if (policy === undefined || dispatcher === undefined) return DIRECT_ROUTE + const proxy = proxyForUrl(policy, url) + return proxy === undefined ? DIRECT_ROUTE : { proxied: true, proxy, dispatcher } } /** @@ -117,7 +144,7 @@ async function createPolicyDispatcher(policy: ProxyPolicy): Promise * @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> { +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 @@ -131,97 +158,39 @@ export async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Pro return Promise.resolve() } } + const previousInstalled = installed const undici = await import('undici') const previous = undici.getGlobalDispatcher() const direct = new undici.Agent() undici.setGlobalDispatcher(direct) active = policy + installed = undefined return async () => { undici.setGlobalDispatcher(previous) active = previousPolicy + installed = previousInstalled await direct.close() } } const restoreEnv = applyPolicyEnv(policy) const { getGlobalDispatcher, setGlobalDispatcher } = await import('undici') const previousDispatcher = getGlobalDispatcher() + const previousInstalled = installed const agent = await createPolicyDispatcher(policy) setGlobalDispatcher(agent) active = policy + installed = agent return async () => { setGlobalDispatcher(previousDispatcher) active = previousPolicy + installed = previousInstalled 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. 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 = {}, - policy: ProxyPolicy = active ?? DIRECT_POLICY, -): Promise { - const undici = await import('undici') - const proxy = proxyForUrl(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 spawned child needs. @@ -251,7 +220,7 @@ export function proxyUrlFor(url: URL): string | undefined { * @returns names to apply to the child environment, where `undefined` means remove, or an empty * object when no proxy is active. */ -export function childProxyEnv(): Readonly> { +export function proxyEnvironmentForChild(): Readonly> { const policy = active const inherited = inheritedProxyEnv if (policy === undefined || policy.source === 'none' || inherited === undefined) return {} @@ -265,3 +234,39 @@ export function childProxyEnv(): Readonly> { } return overlay } + +/** + * Resolve this process's proxy policy from `env` and install it. + * + * Resolution, reporting, and installation are one operation because no caller needs them apart: the + * launcher does all three in sequence before the first plugin mounts, and a policy resolved but not + * installed routes nothing. + * + * A value the environment supplies but this package cannot use is reported and skipped rather than + * thrown: the variable may have been exported for another tool, and a proxy the harness cannot use + * must not stop the agent from starting. + * + * @param env - the launch environment, whose own layering already prefers real variables over `.env` files. + * @param report - receives one message per rejected value, in the order the values were considered. + * @returns a disposer restoring the previous dispatcher, policy, and environment. + */ +export async function installProxyFromEnvironment( + env: EnvLookup, + report: (message: string) => void, +): Promise<() => Promise> { + const { policy, diagnostics } = resolveProxyPolicy(env) + for (const diagnostic of diagnostics) report(diagnostic.message) + return await installGlobalProxy(policy) +} + +/** + * The environment overlay that removes every proxy name from a spawned child. + * + * A harness that replays a recorded session must reach its own fixture server, not the proxy a + * developer or a CI runner exported; `undefined` is how a spawn removes a name it inherits. + * + * @returns one entry per proxy name, each `undefined`. + */ +export function clearedProxyEnv(): Record { + return Object.fromEntries(PROXY_ENV_NAMES.map(name => [name, undefined])) +} diff --git a/packages/util/http-proxy/tests/install.spec.ts b/packages/util/http-proxy/tests/install.spec.ts index cb43ca4c1c..4b02ea1ee8 100644 --- a/packages/util/http-proxy/tests/install.spec.ts +++ b/packages/util/http-proxy/tests/install.spec.ts @@ -2,17 +2,13 @@ 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, PROXY_ENV_NAMES, type ProxyPolicy } from '../src/policy.ts' + clearedProxyEnv, + installProxyFromEnvironment, + proxyEnvironmentForChild, + proxyRouteFor, +} from '../src/index.ts' +import { PROXY_ENV_NAMES } from '../src/policy.ts' /** Absolute-form request targets the fake proxy received; a populated entry proves a request was tunnelled. */ let proxied: string[] = [] @@ -65,14 +61,42 @@ afterEach(() => { /** 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' } +/** A launch environment built from the names a user would export, in the casings they wrote. */ +function env(values: Record): { get(name: string): { value: string } | undefined } { + return { get: name => (name in values ? { value: values[name] as string } : undefined) } } -describe('installGlobalProxy', () => { +/** The environment of a user who exported one proxy for both schemes. */ +function proxyAll(noProxy?: string): { get(name: string): { value: string } | undefined } { + return env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, ...noProxy === undefined ? {} : { NO_PROXY: noProxy } }) +} + +/** Install and collect whatever the resolution reported, so a case can assert on both. */ +async function install( + lookup: { get(name: string): { value: string } | undefined }, +): Promise<{ dispose: () => Promise; reported: string[] }> { + const reported: string[] = [] + const dispose = await installProxyFromEnvironment(lookup, (message) => { reported.push(message) }) + return { dispose, reported } +} + +/** Run one case from a known-empty proxy environment, then restore what the machine had. */ +async function withCleanProxyEnv(run: () => Promise): Promise { + 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) + try { + await run() + } finally { + for (const [name, value] of Object.entries(saved)) { + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } + } +} + +describe('installProxyFromEnvironment', () => { it('routes the built-in global fetch through the proxy', async () => { - const dispose = await installGlobalProxy(proxyAll()) + const { dispose } = await install(proxyAll()) try { await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY') expect(proxied).toEqual([`GET ${proxyTarget}`]) @@ -82,22 +106,38 @@ describe('installGlobalProxy', () => { }) it('connects directly when the bypass list covers the target', async () => { - const dispose = await installGlobalProxy(proxyAll('127.0.0.1')) + const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, NO_PROXY: 'origin.test' })) try { - await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT') + await expect(fetch(proxyTarget, { signal: AbortSignal.timeout(1500) })).rejects.toThrow() expect(proxied).toEqual([]) } finally { await dispose() } }) + it('reports a value it cannot use and installs the rest', async () => { + const { dispose, reported } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' })) + try { + // A variable exported for another tool must not stop the agent from starting, and the user + // has to learn that this scheme stays direct rather than discover it from a failing request. + // The message names the variable, never its value: a proxy URL may carry `user:password`. + expect(reported).toHaveLength(1) + expect(reported[0]).toContain('HTTPS_PROXY') + expect(reported[0]).toContain('SOCKS') + expect(reported[0]).not.toContain('1080') + await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY') + } finally { + await dispose() + } + }) + it('publishes the policy through the proxy environment in both casings', async () => { - const dispose = await installGlobalProxy(proxyAll('example.com')) + const { dispose } = await install(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') + expect(process.env.no_proxy).toContain('example.com') + expect(process.env.NO_PROXY).toContain('example.com') } finally { await dispose() } @@ -105,7 +145,9 @@ describe('installGlobalProxy', () => { 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' }) + // The user named no HTTPS proxy, so the policy derives one from HTTP — the name is rewritten, + // never left carrying a value from an earlier process. + const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' })) try { expect(process.env.HTTPS_PROXY).toBeUndefined() } finally { @@ -115,41 +157,39 @@ describe('installGlobalProxy', () => { } }) - it('restores the dispatcher, the policy, and the environment on disposal', async () => { + it('restores the dispatcher, the route, and the environment on disposal', async () => { const before = getGlobalDispatcher() const beforeEnv = process.env.HTTP_PROXY - const beforePolicy = currentProxyPolicy() - const dispose = await installGlobalProxy(proxyAll()) + const { dispose } = await install(proxyAll()) expect(getGlobalDispatcher()).not.toBe(before) - expect(currentProxyPolicy()).not.toBe(beforePolicy) + expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(true) await dispose() expect(getGlobalDispatcher()).toBe(before) - expect(currentProxyPolicy()).toBe(beforePolicy) + expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(false) 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 () => { + it('installs no dispatcher and touches no environment when the user exported none', async () => { const before = getGlobalDispatcher() process.env.HTTP_PROXY = 'http://untouched.example' - const dispose = await installGlobalProxy(DIRECT_POLICY) + const { dispose, reported } = await install(env({})) try { expect(getGlobalDispatcher()).toBe(before) expect(process.env.HTTP_PROXY).toBe('http://untouched.example') - expect(currentProxyPolicy()).toBe(DIRECT_POLICY) + expect(reported).toEqual([]) + expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false }) } finally { await dispose() delete process.env.HTTP_PROXY } - // With nothing installed the accessor still answers, so a caller never has to spell the direct - // case itself — that spelling is what let two reads disagree about one request. - expect(currentProxyPolicy()).toBe(DIRECT_POLICY) }) + 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' }) + const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: 'socks5://127.0.0.1:1080' })) try { // 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 @@ -165,193 +205,163 @@ describe('installGlobalProxy', () => { }) }) -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(proxyTarget)) - try { - const undici = await import('undici') - const response = await undici.fetch(proxyTarget, { 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() - } - }) - - 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. +describe('proxyRouteFor', () => { + it('carries the dispatcher already routing, so a branch and its request agree', async () => { + const { dispose } = await install(proxyAll()) + const route = proxyRouteFor(new URL(proxyTarget)) + expect(route).toMatchObject({ proxied: true, proxy: proxyUrl }) + if (!route.proxied) throw new Error('unreachable: asserted proxied above') + // One transport, not a copy: a caller that branched on this route sends its request through the + // very agent the branch described, so no second read can put the two on different routes. + expect(route.dispatcher).toBe(getGlobalDispatcher()) + const undici = await import('undici') + // Unmounting the plugin under an in-flight request is what a hot reload does. The shared + // dispatcher is closed, not destroyed, so the hop that already left finishes. + const inFlight = undici.fetch(proxyTarget, { dispatcher: route.dispatcher }) await dispose() - const dispatcher = await createDispatcher(new URL(proxyTarget), {}, branched) + await expect((await inFlight).text()).resolves.toBe('VIA-PROXY') + expect(proxied).toEqual([`GET ${proxyTarget}`]) + }) + + it('is direct for a bypassed URL, and direct with nothing installed', async () => { + const { dispose } = await install(proxyAll('origin.test')) try { - const undici = await import('undici') - await expect((await undici.fetch(proxyTarget, { dispatcher })).text()).resolves.toBe('VIA-PROXY') + expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false }) } finally { - await dispatcher.close() + await dispose() + } + expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false }) + }) + + it('is direct for a loopback URL under a policy that proxies everything', async () => { + const { dispose } = await install(proxyAll()) + try { + expect(proxyRouteFor(new URL(originUrl))).toEqual({ proxied: false }) + } finally { + await dispose() } }) }) -describe('childProxyEnv', () => { +describe('proxyEnvironmentForChild', () => { it('is empty when no policy is installed', () => { - expect(childProxyEnv()).toEqual({}) + expect(proxyEnvironmentForChild()).toEqual({}) }) - it('is empty under a direct policy, so a child sees no flag it cannot use', async () => { - const dispose = await installGlobalProxy(DIRECT_POLICY) + it('is empty when the user exported none, so a child sees no flag it cannot use', async () => { + const { dispose } = await install(env({})) try { - expect(childProxyEnv()).toEqual({}) + expect(proxyEnvironmentForChild()).toEqual({}) } finally { await dispose() } }) 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' - const dispose = await installGlobalProxy(proxyAll('example.com')) - try { - const child = childProxyEnv() - // 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. - 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() - for (const [name, value] of Object.entries(saved)) { - if (value === undefined) Reflect.deleteProperty(process.env, name) - else process.env[name] = value + await withCleanProxyEnv(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 install(env({ HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080', NO_PROXY: 'example.com' })) + try { + const child = proxyEnvironmentForChild() + // 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: it only adds entries to what the user wrote, and + // without the loopback ones the child sends its own localhost traffic to a proxy that + // cannot route it. + expect(child.no_proxy).toBe('example.com,localhost,127.0.0.1,::1,[::1]') + expect(child.NO_PROXY).toBe('example.com,localhost,127.0.0.1,::1,[::1]') + expect(child.NODE_USE_ENV_PROXY).toBe('1') + } finally { + await dispose() } - } + }) }) + 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 + await withCleanProxyEnv(async () => { + // 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 install(env({ ALL_PROXY: proxyUrl })) + try { + const child = proxyEnvironmentForChild() + 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() } - } + }) }) 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) + await withCleanProxyEnv(async () => { + // The user exported one name, in one casing. + process.env.HTTP_PROXY = proxyUrl + // The launcher installs first; mounting the plugin installs a second policy over it. + const outer = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: 'example.com' })) try { - const child = childProxyEnv() - // 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) + const inner = await install(env({ HTTP_PROXY: nestedUrl, HTTPS_PROXY: nestedUrl })) + try { + const child = proxyEnvironmentForChild() + // 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 inner.dispose() + } + // Unmounting the inner install must leave the outer one still able to describe that + // environment; clearing the record instead makes this an empty object, so every later child + // inherits the normalized values from `process.env` untouched. + expect(proxyEnvironmentForChild().HTTP_PROXY).toBe(proxyUrl) + expect(proxyEnvironmentForChild().https_proxy).toBe(proxyUrl) } finally { - await disposeInner() + await outer.dispose() } - // Unmounting the inner install must leave the outer one still able to describe that - // 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().https_proxy).toBe(proxyUrl) - } 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', () => { - it('stops proxying when a direct policy is installed over a proxied one', async () => { - const outer = await installGlobalProxy(proxyAll()) +describe('installing over an existing installation', () => { + it('stops proxying when the mounted policy proxies nothing', async () => { + const outer = await install(proxyAll()) try { await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY') - const off = await installGlobalProxy(DIRECT_POLICY) + const off = await install(env({})) try { // `mode: 'off'` must actually stop proxying, not merely report a direct policy while the // launcher's agent keeps tunnelling. A direct hop needs a host that answers, so this one // reaches the real origin rather than the name only the proxy can resolve. await expect((await fetch(originUrl)).text()).resolves.toBe('DIRECT') - expect(currentProxyPolicy()).toBe(DIRECT_POLICY) + expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false }) } finally { - await off() + await off.dispose() } // Disposing the direct policy restores the proxy the launcher installed. await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY') + expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(true) } finally { - await outer() + await outer.dispose() } }) }) -describe('applyPolicyEnv restoration', () => { +describe('the published environment', () => { 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()) + const { dispose } = await install(proxyAll()) expect(process.env.HTTP_PROXY).toBe(proxyUrl) await dispose() // Reading the uppercase spelling after writing the lowercase one must not restore the value @@ -363,77 +373,10 @@ 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) => { - 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 { - // 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() - } - }) - - 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() +describe('clearedProxyEnv', () => { + it('names every proxy variable for removal, so a replay reaches its own fixture server', () => { + const cleared = clearedProxyEnv() + expect(Object.keys(cleared).sort()).toEqual([...PROXY_ENV_NAMES].sort()) + expect(Object.values(cleared).every(value => value === undefined)).toBe(true) }) }) diff --git a/packages/util/http-proxy/tests/matcher-parity.spec.ts b/packages/util/http-proxy/tests/matcher-parity.spec.ts index e3c7c89ac2..3493eb54b6 100644 --- a/packages/util/http-proxy/tests/matcher-parity.spec.ts +++ b/packages/util/http-proxy/tests/matcher-parity.spec.ts @@ -1,7 +1,8 @@ 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' +import { installProxyFromEnvironment } from '../src/index.ts' +import { proxyForUrl, resolveProxyPolicy } from '../src/policy.ts' /** * `proxyForUrl` answers where a URL goes; these cases check that answer against where a real `fetch` @@ -10,9 +11,9 @@ import { installGlobalProxy, proxyForUrl, type ProxyPolicy } from '../src/index. * 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. + * The remaining second matcher is Node's, in a spawned child: it reads the published `NO_PROXY` and + * 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 }, @@ -46,22 +47,26 @@ afterAll(async () => { await new Promise((resolve) => { proxy.close(() => { resolve() }) }) }) -function policy(noProxy: string): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy, source: 'env' } +/** The launch environment of a user who exported one proxy for both schemes plus one bypass list. */ +function proxyEnv(noProxy: string): { get(name: string): { value: string } | undefined } { + const values: Record = { HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: noProxy } + return { get: name => (name in values ? { value: values[name] as string } : undefined) } } 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)) + const env = proxyEnv(noProxy) + const { policy } = resolveProxyPolicy(env) + const dispose = await installProxyFromEnvironment(env, () => undefined) try { // 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 }) + expect({ ours: proxyForUrl(policy, url) !== undefined, agent: agentProxied }) .toEqual({ ours: !bypassed, agent: !bypassed }) } finally { await dispose() diff --git a/packages/web/web-fetch-http/src/network.ts b/packages/web/web-fetch-http/src/network.ts index a6c6b34953..f14bf276b4 100644 --- a/packages/web/web-fetch-http/src/network.ts +++ b/packages/web/web-fetch-http/src/network.ts @@ -9,8 +9,8 @@ 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, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' +import type { Dispatcher, Response } from 'undici' + import ipaddr from 'ipaddr.js' import { WebError } from '@deepseek-ai/dsh-web' @@ -174,80 +174,35 @@ export function isNonPublicIpLiteral(hostname: string): boolean { } /** - * Fetch through an Undici agent whose lookup callback returns only the already - * validated address set. The URL hostname remains intact for HTTP Host and TLS SNI. + * Fetch through an agent whose lookup callback returns only the already validated address set. The + * URL hostname remains intact for HTTP Host and TLS SNI. * - * @param url - validated HTTP(S) URL. + * The agent is this request's own because the address set is: pinning is how this package refuses a + * DNS answer that changes between validation and connection, and it may not apply process-wide — + * an operator-configured MCP server or model endpoint on loopback is a supported destination, and + * only the URLs this tool fetches are the model's to choose. + * + * @param url - validated HTTP(S) URL the policy does not route through a proxy. * @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. + * @returns a response plus the disposer its consumer must call. */ export async function requestPinned( url: URL, addresses: readonly PublicAddress[], headers: Record, signal: AbortSignal, - policy?: ProxyPolicy, ): Promise { - return await requestWith(url, headers, signal, { - autoSelectFamily: true, - connect: { lookup: createPinnedLookup(addresses) }, - }, policy) -} - -/** - * 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. - * @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, {}, policy) -} - -/** - * 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. - * @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( - url: URL, - 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, policy) + // 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 resolves it here. + const { Agent, fetch } = await import('undici') + // Reached only where `proxyRouteFor` reported no proxy for this URL, and the pinned lookup this + // agent carries is per-request state the process-wide dispatcher cannot hold. + // proxy-exempt: pinning one request's validated addresses, on a URL the policy routes directly. + const dispatcher = new Agent({ autoSelectFamily: true, connect: { lookup: createPinnedLookup(addresses) } }) try { - // proxy-exempt: the dispatcher is createDispatcher's, which already applied the active policy. + // proxy-exempt: the agent above, whose lifetime is this one request. const response = await fetch(url, { method: 'GET', redirect: 'manual', headers, signal, dispatcher }) return { response, close: async () => { await dispatcher.close() } } } catch (error: unknown) { @@ -256,11 +211,38 @@ async function requestWith( } } +/** + * Fetch through the dispatcher the proxy policy already installed, letting the proxy 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. The dispatcher is the process-wide one, so hops share its connection pool and no caller + * closes it. + * + * @param dispatcher - the route's dispatcher, from `proxyRouteFor`. + * @param url - validated HTTP(S) URL the policy routes through a proxy. + * @param headers - request headers. + * @param signal - request and body-read cancellation signal. + * @returns a response plus a disposer that releases nothing, so both paths close alike. + */ +export async function requestVia( + dispatcher: Dispatcher, + url: URL, + headers: Record, + signal: AbortSignal, +): Promise { + const { fetch } = await import('undici') + // proxy-exempt: the dispatcher is the installed policy's own, handed over by `proxyRouteFor`. + const response = await fetch(url, { method: 'GET', redirect: 'manual', headers, signal, dispatcher }) + return { response, close: () => Promise.resolve() } +} + /** Production network operations kept as an object so provider tests can replace resolution only. */ export const publicHttpNetwork = { resolve: resolvePublicAddresses, request: requestPinned, - requestProxied, + requestVia, } type LookupCallback = ( diff --git a/packages/web/web-fetch-http/src/provider.ts b/packages/web/web-fetch-http/src/provider.ts index 01568ce6ed..a6ddbe03a6 100644 --- a/packages/web/web-fetch-http/src/provider.ts +++ b/packages/web/web-fetch-http/src/provider.ts @@ -10,7 +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 } from '@deepseek-ai/dsh-http-proxy' +import { proxyRouteFor } from '@deepseek-ai/dsh-http-proxy' import { isNonPublicIpLiteral, publicHttpNetwork } from './network.ts' import type { PublicAddress } from './network.ts' import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' @@ -125,19 +125,18 @@ export class HttpFetchProvider implements WebFetchProvider { // bypass the proxy. A hop the policy bypasses — every loopback and every `NO_PROXY` entry — // still takes the resolved-and-pinned path unchanged. // - // 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. + // One route decides both the branch and the dispatcher, so a mount or disposal between two + // reads cannot return a direct, unpinned agent for a URL this branch cleared as proxied. // // An IP literal the address checks would refuse never takes it. The proxy would resolve // nothing — the address is already stated — so the shortcut would spend the checks for // nothing and let a proxy on this machine reach the very service they keep out of reach. - const policy = currentProxyPolicy() - if (proxyForUrl(policy, url) !== undefined && !isNonPublicIpLiteral(url.hostname)) { - return await publicHttpNetwork.requestProxied(url, headers, signal, policy) + const route = proxyRouteFor(url) + if (route.proxied && !isNonPublicIpLiteral(url.hostname)) { + return await publicHttpNetwork.requestVia(route.dispatcher, url, headers, signal) } const addresses = await this.resolveAddresses(url.hostname, signal) - return await publicHttpNetwork.request(url, addresses, headers, signal, policy) + 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 index 97f7fa008c..bb75046b35 100644 --- a/packages/web/web-fetch-http/tests/proxy.spec.ts +++ b/packages/web/web-fetch-http/tests/proxy.spec.ts @@ -1,7 +1,7 @@ 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 { installProxyFromEnvironment } 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 { isNonPublicIpLiteral, publicHttpNetwork } from '../src/network.ts' @@ -62,15 +62,19 @@ afterEach(async () => { ]) }) -/** 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' } +/** + * Install the policy of a user who exported one proxy for both schemes; the fixture disposes it + * after every case. + */ +async function installProxy(): Promise<() => Promise> { + const env = { get: (name: string) => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } + return await installProxyFromEnvironment(env, () => undefined) } 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()) + disposeProxy = await installProxy() const result = await new HttpFetchProvider(limits).fetch({ url: proxyTarget }) @@ -81,10 +85,12 @@ describe('fetching through a proxy', () => { expect(resolve).not.toHaveBeenCalled() }) - it('keeps resolving and pinning a hop the bypass list covers', async () => { + it('keeps resolving and pinning a hop the policy does not proxy', async () => { const resolve = vi.spyOn(publicHttpNetwork, 'resolve') .mockResolvedValue([{ address: '127.0.0.1', family: 4 }]) - disposeProxy = await installGlobalProxy(policy('127.0.0.1')) + // No bypass entry needed: a resolved policy never routes loopback through a proxy, which is + // exactly the case this asserts still resolves and pins. + disposeProxy = await installProxy() const result = await new HttpFetchProvider(limits).fetch({ url: originUrl }) @@ -107,7 +113,7 @@ describe('fetching through a proxy', () => { 'refuses %s instead of letting the proxy reach it for us', async (host) => { const resolve = vi.spyOn(publicHttpNetwork, 'resolve') - disposeProxy = await installGlobalProxy(policy()) + disposeProxy = await installProxy() // The proxied path exists because a proxy resolves the origin; a literal needs no resolution, // so taking it would spend the address checks for nothing and hand a proxy on this machine @@ -137,14 +143,14 @@ describe('fetching through a proxy', () => { response.writeHead(302, { location: 'http://elsewhere.example/next' }) response.end() }) - disposeProxy = await installGlobalProxy(policy()) + disposeProxy = await installProxy() await expect(new HttpFetchProvider(limits).fetch({ url: proxyTarget })) .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()) + disposeProxy = await installProxy() await expect(new HttpFetchProvider(limits).fetch({ url: 'ftp://example.com/x' })) .rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' })) diff --git a/packages/web/web-search-deepseek/tests/egress.spec.ts b/packages/web/web-search-deepseek/tests/egress.spec.ts index 3be54fe61d..d37bb838c6 100644 --- a/packages/web/web-search-deepseek/tests/egress.spec.ts +++ b/packages/web/web-search-deepseek/tests/egress.spec.ts @@ -1,7 +1,7 @@ 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' +import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' let seen: string[] = [] let proxy: Server @@ -21,12 +21,13 @@ beforeAll(async () => { }) afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +/** The launch environment of a user who exported one proxy for both schemes. */ +function proxyEnv(): { get(name: string): { value: string } | undefined } { + return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } } async function observe(run: () => Promise): Promise { seen = [] - const dispose = await installGlobalProxy(policy()) + const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) try { await run().catch(() => undefined) } finally { await dispose() } return seen } diff --git a/packages/web/web-search-exa/tests/egress.spec.ts b/packages/web/web-search-exa/tests/egress.spec.ts index 44ef88aaba..b2475abb94 100644 --- a/packages/web/web-search-exa/tests/egress.spec.ts +++ b/packages/web/web-search-exa/tests/egress.spec.ts @@ -1,7 +1,7 @@ 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' +import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' let seen: string[] = [] let proxy: Server @@ -21,12 +21,13 @@ beforeAll(async () => { }) afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +/** The launch environment of a user who exported one proxy for both schemes. */ +function proxyEnv(): { get(name: string): { value: string } | undefined } { + return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } } async function observe(run: () => Promise): Promise { seen = [] - const dispose = await installGlobalProxy(policy()) + const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) try { await run().catch(() => undefined) } finally { await dispose() } return seen } diff --git a/packages/web/web-search-perplexity/tests/egress.spec.ts b/packages/web/web-search-perplexity/tests/egress.spec.ts index 2a698d8142..542cb1db48 100644 --- a/packages/web/web-search-perplexity/tests/egress.spec.ts +++ b/packages/web/web-search-perplexity/tests/egress.spec.ts @@ -1,7 +1,7 @@ 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' +import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' let seen: string[] = [] let proxy: Server @@ -21,12 +21,13 @@ beforeAll(async () => { }) afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) -function policy(): ProxyPolicy { - return { httpProxy: proxyUrl, httpsProxy: proxyUrl, noProxy: '', source: 'env' } +/** The launch environment of a user who exported one proxy for both schemes. */ +function proxyEnv(): { get(name: string): { value: string } | undefined } { + return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } } async function observe(run: () => Promise): Promise { seen = [] - const dispose = await installGlobalProxy(policy()) + const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) try { await run().catch(() => undefined) } finally { await dispose() } return seen } diff --git a/packages/workflow/workflow-worker-thread/tests/egress.spec.ts b/packages/workflow/workflow-worker-thread/tests/egress.spec.ts index b8a34a4fbe..6e99470fdf 100644 --- a/packages/workflow/workflow-worker-thread/tests/egress.spec.ts +++ b/packages/workflow/workflow-worker-thread/tests/egress.spec.ts @@ -1,23 +1,23 @@ import { describe, expect, it } from 'vitest' -import { PROXY_ENV_NAMES, installGlobalProxy, type ProxyPolicy } from '@deepseek-ai/dsh-http-proxy' +import { clearedProxyEnv, installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' import { workerSpawnEnv } from '../src/host.ts' -/** 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', +/** A proxy URL carrying credentials, the shape that must never reach model-authored code. */ +const CREDENTIALED_PROXY = 'http://alice:s3cret@proxy.example:8080' + +/** The launch environment of a user whose proxy needs a password. */ +const CREDENTIALED = { + get: (name: string) => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: CREDENTIALED_PROXY } : undefined), } describe('workflow worker egress', () => { it('hands the worker no proxy configuration, credentialed or not', async () => { - const dispose = await installGlobalProxy(CREDENTIALED) + const dispose = await installProxyFromEnvironment(CREDENTIALED, () => undefined) 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) + for (const name of Object.keys(clearedProxyEnv())) expect(env).not.toHaveProperty(name) expect(env).not.toHaveProperty('NODE_USE_ENV_PROXY') expect(JSON.stringify(env)).not.toContain('s3cret') } finally { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38bc0dfe69..1be8e07d43 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7236,10 +7236,10 @@ importers: '@opentelemetry/api-logs': specifier: ^0.220.0 version: 0.220.0 - '@opentelemetry/exporter-logs-otlp-http': + '@opentelemetry/otlp-exporter-base': specifier: ^0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': + '@opentelemetry/otlp-transformer': specifier: ^0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': @@ -11822,12 +11822,6 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' - '@opentelemetry/exporter-logs-otlp-http@0.220.0': - resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==} - engines: {node: ^18.19.0 || >=20.6.0} - peerDependencies: - '@opentelemetry/api': ^1.3.0 - '@opentelemetry/otlp-exporter-base@0.220.0': resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -17427,15 +17421,6 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.43.0 - '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': - dependencies: - '@opentelemetry/api': 1.9.1 - '@opentelemetry/api-logs': 0.220.0 - '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 diff --git a/scripts/verify-no-bare-dispatcher.spec.ts b/scripts/verify-no-bare-dispatcher.spec.ts index 99034290f8..ce6bbe6ec7 100644 --- a/scripts/verify-no-bare-dispatcher.spec.ts +++ b/scripts/verify-no-bare-dispatcher.spec.ts @@ -36,7 +36,7 @@ describe('bare dispatcher check', () => { }) it('accepts the sanctioned factory', () => { - expect(reasons(' const dispatcher = await createDispatcher(url, options)')).toEqual([]) + expect(reasons(' const route = proxyRouteFor(url)')).toEqual([]) }) it('rejects the shorthand form a line-wise regex misses', () => { diff --git a/scripts/verify-no-bare-dispatcher.ts b/scripts/verify-no-bare-dispatcher.ts index ec20c9c3ea..2b08dea968 100644 --- a/scripts/verify-no-bare-dispatcher.ts +++ b/scripts/verify-no-bare-dispatcher.ts @@ -7,7 +7,10 @@ * — 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. + * `proxyRouteFor(url)` from that package is the sanctioned way to ask where one request goes and to + * get the transport that answer assumed. A call site that genuinely owns its transport — because it + * carries per-request state the process-wide dispatcher cannot, as `web-fetch-http`'s address + * pinning does — says so with the marker below. * * 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 @@ -215,7 +218,7 @@ function main(): void { 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') + console.error('\nUse `proxyRouteFor(url)` 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) } From b518286735c69507e4567ddeb7b3d087c841fce5 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 21:42:09 +0800 Subject: [PATCH 17/43] fix(session-telemetry-otel): keep gzip on the fetch transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit moved the OTLP exporter to the SDK's `fetch` delegate and refused `exporter.compression` at load, on the belief that nothing shipped enabled it. `packages/bundle/base/cordis.patch.yml` does, so the refusal broke every test that boots the shipped bundle — the snapshot, e2e, and Windows observational jobs all failed on that one load error. Dropping gzip was the wrong trade anyway: a realistic OTLP batch measures 6.4x smaller with it, so trading it for proxy support would have charged every deployment to fix one. The `fetch` transport has no compression hook, but serialization is the seam before the body reaches it — the plugin now gzips there and declares `Content-Encoding` itself. `keepAlive` and `httpAgentOptions` have no such seam, since they configure a connection pool `fetch` does not expose, so those two stay refused at load rather than accepted and ignored. `compression` is typed as the two values this package can actually apply rather than the SDK's wider enum, and a third value fails loud at load. --- ...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/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 17 +++- docs/config-catalog.zh.md | 17 +++- .../session-telemetry-otel/src/index.ts | 73 +++++++++++++--- .../session-telemetry-otel/tests/otel.spec.ts | 85 +++++++++++++++++-- 8 files changed, 179 insertions(+), 29 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 f1a7262652..aa2d48bccc 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: 88bfe542d322d2caee5f5e220ed211e0169fa614 -2026-08-27-outbound-proxy-policy.zh.md: f5afee9de40e2032cfd4eda3bc9bfb1c627e71aa +2026-08-27-outbound-proxy-policy.md: 9c58dfd00d9b6c0b4438ad3dd4da58d8706f718f +2026-08-27-outbound-proxy-policy.zh.md: 1aabc716c1bdf348229b260c4d8580fb4edc8637 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 88bfe542d3..9c58dfd00d 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 @@ -50,7 +50,9 @@ This accepts a documented seam. Such a context matches bypass entries by Node's The exporter now composes `OTLPExporterBase` with `createLegacyOtlpBrowserExportDelegate` — a published entry point of the same SDK package, and the one that posts through `fetch`. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes. -Switching the exporter to `fetch` costs `compression`: gzip belongs to the SDK's Node transport, and a realistic OTLP batch measured 6.4x smaller with it. Nothing shipped enabled it, and telemetry that ignores the proxy simply fails inside a corporate network, so routing wins. What the exporter would silently ignore, the plugin now refuses at load — `exporter.compression`, `exporter.keepAlive`, and `exporter.httpAgentOptions` throw with the reason, so no deployment pays the difference without seeing it. In exchange the Node-version floor disappears: `proxyEnv` on an `http.Agent` needs 22.21 or 24.5, inside the engines range, so telemetry used to stay direct on 22.19, 22.20, and 24.0–24.4. +The `fetch` transport has no compression, and the shipped `base` bundle enables gzip — a realistic OTLP batch measures 6.4x smaller with it. Dropping it to gain proxy support would have traded one deployment's problem for every deployment's, and the first attempt did exactly that: it refused `exporter.compression` at load, which broke every test that boots the shipped bundle. This package gzips at the serializer instead, the one seam before the body reaches the transport, and declares `Content-Encoding` itself. `keepAlive` and `httpAgentOptions` have no such seam — they configure a connection pool `fetch` does not expose — so those two are refused at load rather than accepted and ignored. + +In exchange the Node-version floor disappears: `proxyEnv` on an `http.Agent` needs 22.21 or 24.5, inside the engines range, so telemetry used to stay direct on 22.19, 22.20, and 24.0–24.4. **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. 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 f5afee9de4..1aabc716c1 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 @@ -50,7 +50,9 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 导出器改为用 `OTLPExporterBase` 组合 `createLegacyOtlpBrowserExportDelegate`——同一个 SDK 包的公开入口,也是通过 `fetch` 投递的那一个。E2B 则接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。 -把导出器换到 `fetch` 的代价是 `compression`:gzip 属于该 SDK 的 Node 传输,实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。目前没有任何随附配置启用它,而在企业代理网络里,不遵循代理的遥测干脆发不出去,因此路由优先。导出器本会静默忽略的选项,现在由插件在加载期拒绝——`exporter.compression`、`exporter.keepAlive` 与 `exporter.httpAgentOptions` 会带着原因抛错,任何部署都不会在看不见的情况下承担这个差价。换来的是 Node 版本下限消失:`http.Agent` 的 `proxyEnv` 需要 22.21 或 24.5,而这落在 engines 范围之内,因此遥测过去在 22.19、22.20 与 24.0–24.4 上一直是直连。 +`fetch` 传输没有压缩能力,而随附的 `base` bundle 启用了 gzip——实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。为了拿到代理支持而丢掉它,等于用每个部署的代价去换一个部署的问题;第一版正是这么做的:它在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败。改为由本包在 serializer 处 gzip——那是请求体抵达传输前的唯一接缝——并自行声明 `Content-Encoding`。`keepAlive` 与 `httpAgentOptions` 没有这样的接缝,它们配置的是 `fetch` 不暴露的连接池,因此这两个仍在加载期拒绝,而不是被接受后忽略。 + +换来的是 Node 版本下限消失:`http.Agent` 的 `proxyEnv` 需要 22.21 或 24.5,而这落在 engines 范围之内,因此遥测过去在 22.19、22.20 与 24.0–24.4 上一直是直连。 **每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、遥测、E2B、派生的子 Node 与 worker 线程。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ad6e34e92e..78d84cbf06 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: 6fdd34855e4456237464bfb225b65b55b86fbf2e -config-catalog.zh.md: 9ca1423556ecee7f839c0ef66b5628fd036b6f94 +config-catalog.md: e451d76631252a1457bfc225784adc096c9ad548 +config-catalog.zh.md: b54d57d3a63693d8f6e2e83081c7116b6c5ced5c diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6fdd34855e..e451d76631 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1971,13 +1971,16 @@ export interface Config { * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the * one field this package requires and validates itself. * - * The transport is the SDK's `fetch` one, so the three options that exist - * only for its `node:http` transport — `compression`, `keepAlive`, and - * `httpAgentOptions` — are refused at load rather than ignored. + * The transport is the SDK's `fetch` one, so `keepAlive` and + * `httpAgentOptions` — which configure its `node:http` transport — are + * refused at load rather than ignored. `compression` is honored by this + * package instead of by that transport. */ exporter?: OTLPExporterConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string + /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */ + compression?: SupportedCompression } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -1994,11 +1997,17 @@ export enum SessionTelemetryMode { FEEDBACK_ONLY = 'FEEDBACK_ONLY', DISABLED = 'DISABLED', } + +/** + * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`, + * which also spells `deflate`: the `fetch` transport offers no seam to apply that one. + */ +export type SupportedCompression = 'gzip' | 'none' ``` Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 9ca1423556..b54d57d3a6 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1973,13 +1973,16 @@ export interface Config { * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the * one field this package requires and validates itself. * - * The transport is the SDK's `fetch` one, so the three options that exist - * only for its `node:http` transport — `compression`, `keepAlive`, and - * `httpAgentOptions` — are refused at load rather than ignored. + * The transport is the SDK's `fetch` one, so `keepAlive` and + * `httpAgentOptions` — which configure its `node:http` transport — are + * refused at load rather than ignored. `compression` is honored by this + * package instead of by that transport. */ exporter?: OTLPExporterConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string + /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */ + compression?: SupportedCompression } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -1996,11 +1999,17 @@ export enum SessionTelemetryMode { FEEDBACK_ONLY = 'FEEDBACK_ONLY', DISABLED = 'DISABLED', } + +/** + * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`, + * which also spells `deflate`: the `fetch` transport offers no seam to apply that one. + */ +export type SupportedCompression = 'gzip' | 'none' ``` 依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterConfigBase`(`@opentelemetry/otlp-exporter-base`) -来源:[`packages/session/session-telemetry-otel/src/index.ts:94`](../packages/session/session-telemetry-otel/src/index.ts) +来源:[`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts) diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index b9d5e5477b..ad84f0b5cc 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -13,6 +13,7 @@ */ import { createRequire } from 'node:module' +import { gzipSync } from 'node:zlib' import z from '@deepseek-ai/schemastery' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-command-feedback' @@ -34,6 +35,7 @@ import { import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base' import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http' import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer' +import type { ISerializer } from '@opentelemetry/otlp-transformer' import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base' import type { ReadableLogRecord } from '@opentelemetry/sdk-logs' import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' @@ -100,13 +102,16 @@ export interface Config { * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the * one field this package requires and validates itself. * - * The transport is the SDK's `fetch` one, so the three options that exist - * only for its `node:http` transport — `compression`, `keepAlive`, and - * `httpAgentOptions` — are refused at load rather than ignored. + * The transport is the SDK's `fetch` one, so `keepAlive` and + * `httpAgentOptions` — which configure its `node:http` transport — are + * refused at load rather than ignored. `compression` is honored by this + * package instead of by that transport. */ exporter?: OTLPExporterConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string + /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */ + compression?: SupportedCompression } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -142,7 +147,45 @@ const MAX_TIMER_DELAY_MILLIS = 2_147_483_647 * Exporter options the SDK defines only for its `node:http` transport. They reach the `fetch` * transport this package uses, which silently ignores every one of them. */ -const NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS = ['compression', 'keepAlive', 'httpAgentOptions'] as const +const NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS = ['keepAlive', 'httpAgentOptions'] as const + +/** The one encoding {@link gzipSerializer} applies, spelled as the OTLP `Content-Encoding` spells it. */ +const GZIP = 'gzip' + +/** + * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`, + * which also spells `deflate`: the `fetch` transport offers no seam to apply that one. + */ +export type SupportedCompression = 'gzip' | 'none' + +/** {@link SupportedCompression} as values, for the load-time check on a configuration typed `any`. */ +const SUPPORTED_COMPRESSION: readonly string[] = [GZIP, 'none'] satisfies SupportedCompression[] + +/** + * Wrap a serializer so every batch it produces is gzipped. + * + * The SDK compresses in its `node:http` transport, which the `fetch` transport this package uses + * does not have; serialization is the one seam before the body reaches that transport. The shipped + * profile enables gzip, and a realistic batch measures over six times smaller with it, so dropping + * compression to gain proxy support would trade one deployment's problem for every deployment's. + * + * `gzipSync` runs on the export path, but a batch is bounded by `maxExportBatchSize` and exports are + * already off the request path — the batch processor schedules them. + * + * @param serializer - the SDK serializer producing the uncompressed request body. + * @returns a serializer producing the gzipped body, deserializing responses unchanged. + */ +function gzipSerializer(serializer: ISerializer): ISerializer { + return { + ...serializer, + serializeRequest: (request) => { + const serialized = serializer.serializeRequest(request) + // The SDK returns nothing for a batch it could not serialize. Gzipping that would post an + // empty frame the collector accepts as a valid, empty export. + return serialized === undefined ? undefined : gzipSync(serialized) + }, + } +} /** Severity mapping from the Service Definition's three-level vocabulary to OTel severity numbers. */ const SEVERITY: Record = { @@ -195,12 +238,19 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`) } - // Options that exist only for the SDK's `node:http` transport, which this package no longer - // uses. The exporter would accept and ignore each one, so a deployment that asked for gzip - // would quietly send uncompressed batches; refusing at load is what makes the change visible. + // `keepAlive` and `httpAgentOptions` configure the SDK's `node:http` transport, which this + // package does not use — the `fetch` transport is what reaches a configured proxy. The exporter + // would accept and ignore them, so a deployment would believe it had tuned a connection it had + // not. `compression` is the third such option and is honored instead of refused, below. const nodeOnly = NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS.filter(name => name in exporter) if (nodeOnly.length > 0) { - throw new Error(`session-telemetry-otel: exporter.${nodeOnly.join(', exporter.')} not supported: telemetry is exported through fetch so a configured proxy carries it, and the node:http transport those options belong to would need an http.Agent this package no longer builds`) + throw new Error(`session-telemetry-otel: exporter.${nodeOnly.join(', exporter.')} not supported: telemetry is exported through fetch, whose connections Node owns; ${nodeOnly.length === 1 ? 'that option belongs' : 'those options belong'} to the node:http transport this package no longer builds an agent for`) + } + // Compared as strings because that is what arrives: the schema validates this object as `any`, + // so a cordis.yml may name any algorithm, including one the SDK's enum does not spell. + const compression: string = exporter.compression ?? 'none' + if (!SUPPORTED_COMPRESSION.includes(compression)) { + throw new Error(`session-telemetry-otel: exporter.compression must be one of ${SUPPORTED_COMPRESSION.map(value => JSON.stringify(value)).join(', ')}, got ${JSON.stringify(compression)}`) } // The one processor field checked beyond the SDK's own validation: the // SDK accepts a non-positive batch size, but its shutdown drain then @@ -251,9 +301,12 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { // oxlint-disable-next-line typescript/no-deprecated -- the SDK exports its replacement from no public subpath at 0.220. createLegacyOtlpBrowserExportDelegate( exporter, - JsonLogsSerializer, + compression === GZIP ? gzipSerializer(JsonLogsSerializer) : JsonLogsSerializer, 'v1/logs', - { 'Content-Type': 'application/json' }, + { + 'Content-Type': 'application/json', + ...compression === GZIP ? { 'Content-Encoding': GZIP } : {}, + }, ), ), }), diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index d8a9eec2db..4563bc7475 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -12,6 +12,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { gunzipSync } from 'node:zlib' +import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer' import { Context } from '@deepseek-ai/cordis' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -255,17 +256,91 @@ describe('OpenTelemetrySessionBackend wire', () => { expect(types).toContain('turn/start') }) + it('gzips the batch when the shipped profile asks for it', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + // The shipped `base` bundle sets this, and a realistic batch is over six times smaller with it. + // The SDK compresses in its `node:http` transport, which the `fetch` transport used here does + // not have, so this package gzips at the serializer and declares the encoding itself. + const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { + mode: SessionTelemetryMode.FULL, + exporter: { url, compression: 'gzip' }, + }) + const session = ctx.sessions.create(SessionId('gzip'), { meta: {} }) + session.append('turn/start', { turn: 1 }) + await fiber.dispose() + + expect(captures.length).toBeGreaterThan(0) + expect(captures[0]!.headers['content-encoding']).toBe('gzip') + // The collector gunzips the body it received, so the header is not merely asserted alongside a + // plaintext payload the encoding would have misdescribed. + const types = allRecords(captures).flatMap(({ record }) => + record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? []) + expect(types).toContain('turn/start') + }) + + it('sends the batch uncompressed when no compression is configured', async () => { + const { url, captures } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, exporter: { url } }) + ctx.sessions.create(SessionId('plain'), { meta: {} }).append('turn/start', { turn: 1 }) + await fiber.dispose() + + expect(captures.length).toBeGreaterThan(0) + expect(captures[0]!.headers['content-encoding']).toBeUndefined() + }) + + it('sends nothing when the SDK cannot serialize the batch, rather than an empty gzip frame', async () => { + const { url, captures } = await mockCollector() + const serialize = vi.spyOn(JsonLogsSerializer, 'serializeRequest').mockReturnValue(undefined) + try { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { + mode: SessionTelemetryMode.FULL, + exporter: { url, compression: 'gzip' }, + }) + ctx.sessions.create(SessionId('unserializable'), { meta: {} }).append('turn/start', { turn: 1 }) + await fiber.dispose() + expect(serialize).toHaveBeenCalled() + expect(captures).toEqual([]) + } finally { + serialize.mockRestore() + } + }) + + it('names every node:http option a configuration set, not just the first', async () => { + const { url } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + await expect(ctx.plugin(OpenTelemetrySessionBackend, { + mode: SessionTelemetryMode.FULL, + exporter: { url, keepAlive: true, httpAgentOptions: {} }, + } as unknown as Config)).rejects.toThrow(/exporter\.keepAlive, exporter\.httpAgentOptions not supported/) + }) + it('refuses an exporter option that belongs to the node:http transport', async () => { const { url } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) - // Telemetry goes through `fetch` so a configured proxy carries it, and that transport ignores - // `compression`. Accepting the option would send uncompressed batches while the configuration - // said gzip; the deployment has to see the trade rather than pay it silently. + // `keepAlive` tunes a `node:http` connection pool this package no longer builds. Accepting it + // would let a deployment believe it had tuned a connection that does not exist. await expect(ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, - exporter: { url, compression: 'gzip' }, - } as unknown as Config)).rejects.toThrow(/exporter\.compression not supported/) + exporter: { url, keepAlive: true }, + } as unknown as Config)).rejects.toThrow(/exporter\.keepAlive not supported/) + }) + + it('refuses a compression algorithm it cannot apply', async () => { + const { url } = await mockCollector() + const ctx = new Context() + await ctx.plugin(SessionStore) + await expect(ctx.plugin(OpenTelemetrySessionBackend, { + mode: SessionTelemetryMode.FULL, + exporter: { url, compression: 'deflate' }, + } as unknown as Config)).rejects.toThrow(/exporter\.compression must be one of "gzip", "none"/) }) it('maps warn severity from record policy and leaves the seam flush hint unimplemented', async () => { From 13daefe0733e1c1b2aef449ba8a0d2e5fcc6c850 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 21:51:29 +0800 Subject: [PATCH 18/43] revert(session-telemetry-otel): leave telemetry on its own transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry was the only call site this PR could not cover without changing the SDK transport underneath it, and both ways of doing that cost more than the channel is worth. Routing an `http.Agent` needs Node's `proxyEnv`, added in 22.21 and 24.5 — inside the engines range, so three supported runtimes stayed direct anyway, and the proxy package had to keep a `createNodeHttpAgent` export for a path that only sometimes worked. Replacing the transport with the SDK's `fetch` delegate covered every runtime but has no compression, while the shipped `base` bundle enables gzip and a realistic OTLP batch is 6.4x smaller with it; keeping both meant gzipping at the serializer, which put transport code inside a telemetry plugin. Telemetry is the one outbound channel whose loss costs the user nothing: no tool, model request, or session depends on it, and an export that cannot connect is already dropped silently. A user behind a mandatory proxy is left where they were rather than regressed. `src/index.ts`, `otel.spec.ts`, and `tsconfig.json` return to their state on master; the package keeps only a dev dependency on the proxy library. `egress.spec.ts` inverts: it installs a policy and asserts the fake proxy saw nothing, so an SDK upgrade that moved the exporter onto `fetch` would surface as a failing test rather than silently routing telemetry. --- ...2026-08-27-outbound-proxy-policy.i18n.yaml | 4 +- .../2026-08-27-outbound-proxy-policy.md | 14 +-- .../2026-08-27-outbound-proxy-policy.zh.md | 14 +-- THIRD_PARTY_NOTICES.md | 2 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 25 +--- docs/config-catalog.zh.md | 25 +--- docs/user/guide/network-proxy.i18n.yaml | 4 +- docs/user/guide/network-proxy.md | 1 + docs/user/guide/network-proxy.zh.md | 1 + .../session-telemetry-otel/package.json | 7 +- .../session-telemetry-otel/src/index.ts | 112 ++---------------- .../tests/egress.spec.ts | 71 +++++------ .../session-telemetry-otel/tests/otel.spec.ts | 96 +-------------- .../session-telemetry-otel/tsconfig.json | 3 - packages/util/http-proxy/README.i18n.yaml | 4 +- packages/util/http-proxy/README.md | 7 +- packages/util/http-proxy/README.zh.md | 7 +- pnpm-lock.yaml | 19 ++- 19 files changed, 107 insertions(+), 313 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 aa2d48bccc..d379de0f0f 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: 9c58dfd00d9b6c0b4438ad3dd4da58d8706f718f -2026-08-27-outbound-proxy-policy.zh.md: 1aabc716c1bdf348229b260c4d8580fb4edc8637 +2026-08-27-outbound-proxy-policy.md: a35b8a909eaa1526d3268e475de4d3f7e093b6eb +2026-08-27-outbound-proxy-policy.zh.md: 278d86faa186c36a289eb477f8b741d46a1dfb0c 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 9c58dfd00d..a35b8a909e 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 @@ -24,7 +24,7 @@ An earlier revision put it in a new `net/` package group, reasoning that dependi The plugin that revision shipped is gone with it. It let a composition declare the policy in `cordis.yml`, but no shipped bundle mounted it, so the launcher's path was the only reachable one — and its `Config` was the sole supplier of a configuration branch nothing else could reach. -**Four functions, because the call sites converged rather than the package growing an export each.** An earlier revision exported six: a dispatcher factory, a `node:http` agent factory, a proxy-URL lookup, a policy accessor, an installer, and a child-environment builder. Each existed for one SDK's transport, which is how a transport-policy package turns into a catalogue of other packages' constraints. Review asked whether the call sites could converge instead; they could, and each removal took a whole shape with it. The exporter moved to the SDK's `fetch` delegate, retiring the `node:http` factory. `web-fetch-http` builds its own pinning agent under an annotated exemption, retiring the dispatcher factory. E2B reads `route.proxy`, retiring the proxy-URL lookup. +**Four functions, because the call sites converged rather than the package growing an export each.** An earlier revision exported six: a dispatcher factory, a `node:http` agent factory, a proxy-URL lookup, a policy accessor, an installer, and a child-environment builder. Each existed for one SDK's transport, which is how a transport-policy package turns into a catalogue of other packages' constraints. Review asked whether the call sites could converge instead; they could, and each removal took a whole shape with it. Telemetry stopped being routed at all, retiring the `node:http` factory. `web-fetch-http` builds its own pinning agent under an annotated exemption, retiring the dispatcher factory. E2B reads `route.proxy`, retiring the proxy-URL lookup. What remains is `installProxyFromEnvironment`, `proxyRouteFor`, `proxyEnvironmentForChild`, and `clearedProxyEnv` — one per way a caller can need the policy, none per SDK. Installation absorbed resolution and diagnostic reporting, which no caller needed apart: a resolved policy that is not installed routes nothing. @@ -46,15 +46,13 @@ The URL-level policy is untouched: `http(s)` only, no embedded credentials, the 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 were fixed by moving the call site onto a transport the dispatcher already covers rather than by giving this package a second export for each. +**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. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes. Telemetry is deliberately left direct, and that exclusion is the more interesting half. -The exporter now composes `OTLPExporterBase` with `createLegacyOtlpBrowserExportDelegate` — a published entry point of the same SDK package, and the one that posts through `fetch`. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes. +**Telemetry stays direct on purpose.** Routing it needs one of two things, and both cost more than the channel is worth. An `http.Agent` reads the environment through `proxyEnv`, which arrived in Node 22.21 and 24.5 — inside the engines range, so 22.19, 22.20, and 24.0–24.4 would stay direct regardless, and the proxy package would have to keep a `createNodeHttpAgent` export for a path that works on some runtimes. Replacing the transport with the SDK's `fetch` delegate covers every runtime, but that delegate has no compression, and the shipped `base` bundle enables gzip: a realistic OTLP batch measures 6.4x smaller with it. An attempt that refused `exporter.compression` instead broke every test that boots the shipped bundle, and one that gzipped at the serializer worked but put transport code in a telemetry plugin to keep it working. -The `fetch` transport has no compression, and the shipped `base` bundle enables gzip — a realistic OTLP batch measures 6.4x smaller with it. Dropping it to gain proxy support would have traded one deployment's problem for every deployment's, and the first attempt did exactly that: it refused `exporter.compression` at load, which broke every test that boots the shipped bundle. This package gzips at the serializer instead, the one seam before the body reaches the transport, and declares `Content-Encoding` itself. `keepAlive` and `httpAgentOptions` have no such seam — they configure a connection pool `fetch` does not expose — so those two are refused at load rather than accepted and ignored. +Weighed against that, telemetry is the one outbound channel whose loss costs the user nothing: no tool, no model request, and no session depends on it, and an export that cannot connect is already dropped silently. A user behind a mandatory proxy is left exactly where they were before this change rather than regressed. `egress.spec.ts` now asserts the exclusion — an SDK upgrade that moved the exporter onto `fetch` would start routing telemetry through a proxy silently, and that case is what makes it visible. -In exchange the Node-version floor disappears: `proxyEnv` on an `http.Agent` needs 22.21 or 24.5, inside the engines range, so telemetry used to stay direct on 22.19, 22.20, and 24.0–24.4. - -**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. +**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, E2B, a spawned child Node, a worker thread, and telemetry's exclusion. 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` 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. `proxyRouteFor(url)` is the sanctioned replacement, and the one call site that genuinely owns its transport — `web-fetch-http`, pinning a request to addresses it validated — 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. @@ -94,6 +92,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 `proxyRouteFor`, accepts an annotated exemption, and passes on the current tree. -The egress suite carries the negative case for telemetry — a `node:http` request under the same installed policy reaches no proxy — so a return to the SDK's Node transport cannot quietly un-proxy it. Its positive case no longer branches on the runtime, because `fetch` reaches the dispatcher on every supported Node. 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. +The egress suite carries telemetry's case in the negative: the shipped backend exports under an installed policy and the fake proxy sees nothing, so the deliberate exclusion is asserted rather than merely documented. 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 1aabc716c1..278d86faa1 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 @@ -24,7 +24,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 那次修订一并引入的插件也随之删除。它让某个组合可以把策略写进 `cordis.yml`,但没有任何随附 bundle 挂载它,因此启动器那条路径是唯一可达的——而它的 `Config` 是那条配置分支唯一的供给方,别处无从到达。 -**四个函数——收敛的是调用方,而不是让本包为每个 SDK 各加一个导出。** 早先一版导出六个:dispatcher 工厂、`node:http` agent 工厂、代理 URL 查询、策略访问器、安装器与子进程环境构造器。每一个都为某个 SDK 的传输而存在,而这正是一个传输策略包退化成「别的包的约束目录」的过程。Review 问能不能反过来让调用方收敛;能,而且每删掉一个导出都带走了一整种写法。导出器改用 SDK 的 `fetch` delegate,`node:http` agent 工厂随之退场。`web-fetch-http` 在带注释的豁免下自建 pin agent,dispatcher 工厂随之退场。E2B 读 `route.proxy`,代理 URL 查询随之退场。 +**四个函数——收敛的是调用方,而不是让本包为每个 SDK 各加一个导出。** 早先一版导出六个:dispatcher 工厂、`node:http` agent 工厂、代理 URL 查询、策略访问器、安装器与子进程环境构造器。每一个都为某个 SDK 的传输而存在,而这正是一个传输策略包退化成「别的包的约束目录」的过程。Review 问能不能反过来让调用方收敛;能,而且每删掉一个导出都带走了一整种写法。遥测不再被路由,`node:http` agent 工厂随之退场。`web-fetch-http` 在带注释的豁免下自建 pin agent,dispatcher 工厂随之退场。E2B 读 `route.proxy`,代理 URL 查询随之退场。 剩下的是 `installProxyFromEnvironment`、`proxyRouteFor`、`proxyEnvironmentForChild` 与 `clearedProxyEnv`——按「调用方需要策略的方式」各一个,而不是按 SDK 各一个。安装吸收了解析与诊断上报,因为没有调用方需要把它们分开:解析出来却不安装的策略什么也路由不了。 @@ -46,15 +46,13 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 这接受了一处已记录的接缝。此类上下文按 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。两者都实测为直连,而修复方式不是给本包各加一个导出,而是把调用点搬到 dispatcher 本就覆盖的传输上。 +**有两个 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。两者都实测为直连。E2B 接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。遥测则被有意保留为直连,而这个排除项才是更值得说的一半。 -导出器改为用 `OTLPExporterBase` 组合 `createLegacyOtlpBrowserExportDelegate`——同一个 SDK 包的公开入口,也是通过 `fetch` 投递的那一个。E2B 则接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。 +**遥测的直连是有意为之。** 要让它走代理只有两条路,代价都超过这条通道本身的价值。`http.Agent` 通过 `proxyEnv` 读取环境,而该选项自 Node 22.21 与 24.5 才有——落在 engines 范围之内,因此 22.19、22.20 与 24.0–24.4 无论如何仍是直连,而代理包还得为一条只在部分运行时生效的路径保留 `createNodeHttpAgent` 导出。改用 SDK 的 `fetch` delegate 替换传输可以覆盖所有运行时,但该 delegate 没有压缩能力,而随附的 `base` bundle 启用了 gzip:实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。曾有一版转而在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败;另一版在 serializer 处 gzip 确实能跑通,但代价是把传输层代码塞进了遥测插件。 -`fetch` 传输没有压缩能力,而随附的 `base` bundle 启用了 gzip——实测一批真实规模的 OTLP 数据启用后体积只有 1/6.4。为了拿到代理支持而丢掉它,等于用每个部署的代价去换一个部署的问题;第一版正是这么做的:它在加载期拒绝 `exporter.compression`,结果凡是启动随附 bundle 的测试全部失败。改为由本包在 serializer 处 gzip——那是请求体抵达传输前的唯一接缝——并自行声明 `Content-Encoding`。`keepAlive` 与 `httpAgentOptions` 没有这样的接缝,它们配置的是 `fetch` 不暴露的连接池,因此这两个仍在加载期拒绝,而不是被接受后忽略。 +与之相比,遥测是唯一一条丢失了对用户毫无代价的出网通道:没有任何工具、模型请求或会话依赖它,而连不上的导出本就被静默丢弃。处在强制代理后的用户,只是停留在本次改动之前的状态,而不是被弄坏。`egress.spec.ts` 现在断言这一排除——若某次 SDK 升级把导出器挪到 `fetch` 上,遥测就会开始静默走代理,而该用例正是让这件事暴露出来的东西。 -换来的是 Node 版本下限消失:`http.Agent` 的 `proxyEnv` 需要 22.21 或 24.5,而这落在 engines 范围之内,因此遥测过去在 22.19、22.20 与 24.0–24.4 上一直是直连。 - -**每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、遥测、E2B、派生的子 Node 与 worker 线程。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。 +**每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `egress.spec.ts` 驱动该点的真实代码路径,目标是无法解析的 `.invalid` 主机,穿过一个假代理,并断言代理确实收到了请求。九份测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、E2B、派生的子 Node、worker 线程,以及遥测的排除。下面那条门禁看不进依赖内部;这些能,它们把「某个 SDK 换了传输」从静默回归变成失败的测试。 **用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;唯一一处确实自有传输的调用点——`web-fetch-http`,它把请求钉在已校验的地址上——用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。 @@ -94,6 +92,6 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l `verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。 -出网测试为遥测保留了负向用例——在同一份已安装策略下发一个 `node:http` 请求,触及不到代理——因此改回 SDK 的 Node 传输无法悄悄把遥测变回直连。其正向用例不再按运行时分支,因为在所有受支持的 Node 上 `fetch` 都会落到 dispatcher。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。 +出网测试以负向形式承载遥测这一项:随附后端在已安装策略下执行导出,而假代理什么也没收到——这个有意的排除因此是被断言的,而不只是被记录的。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。 无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1e86e8393f..659018f35d 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -48,8 +48,8 @@ External packages that a workspace package resolves at runtime. The tier covers | [`@openai/codex`](https://github.com/openai/codex) | Apache-2.0 | | [`@opentelemetry/api`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/api-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | +| [`@opentelemetry/exporter-logs-otlp-http`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/otlp-exporter-base`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | -| [`@opentelemetry/otlp-transformer`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/resources`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@opentelemetry/sdk-logs`](https://github.com/open-telemetry/opentelemetry-js) | Apache-2.0 | | [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 78d84cbf06..bf2268d47e 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: e451d76631252a1457bfc225784adc096c9ad548 -config-catalog.zh.md: b54d57d3a63693d8f6e2e83081c7116b6c5ced5c +config-catalog.md: 15a0a279021c76232c4720991e1ed7f105696cbe +config-catalog.zh.md: 5145f7e91db48f8279d5ca6cf02d4b4a8f505f69 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index e451d76631..15a0a27902 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1967,20 +1967,13 @@ export interface Config { mode?: SessionTelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete - * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`, - * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the - * one field this package requires and validates itself. - * - * The transport is the SDK's `fetch` one, so `keepAlive` and - * `httpAgentOptions` — which configure its `node:http` transport — are - * refused at load rather than ignored. `compression` is honored by this - * package instead of by that transport. + * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, + * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` + * is the one field this package requires and validates itself. */ - exporter?: OTLPExporterConfigBase & { + exporter?: OTLPExporterNodeConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string - /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */ - compression?: SupportedCompression } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -1997,17 +1990,11 @@ export enum SessionTelemetryMode { FEEDBACK_ONLY = 'FEEDBACK_ONLY', DISABLED = 'DISABLED', } - -/** - * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`, - * which also spells `deflate`: the `fetch` transport offers no seam to apply that one. - */ -export type SupportedCompression = 'gzip' | 'none' ``` -Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterConfigBase` (`@opentelemetry/otlp-exporter-base`) +Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b54d57d3a6..5145f7e91d 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1969,20 +1969,13 @@ export interface Config { mode?: SessionTelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete - * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`, - * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the - * one field this package requires and validates itself. - * - * The transport is the SDK's `fetch` one, so `keepAlive` and - * `httpAgentOptions` — which configure its `node:http` transport — are - * refused at load rather than ignored. `compression` is honored by this - * package instead of by that transport. + * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, + * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` + * is the one field this package requires and validates itself. */ - exporter?: OTLPExporterConfigBase & { + exporter?: OTLPExporterNodeConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string - /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */ - compression?: SupportedCompression } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -1999,17 +1992,11 @@ export enum SessionTelemetryMode { FEEDBACK_ONLY = 'FEEDBACK_ONLY', DISABLED = 'DISABLED', } - -/** - * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`, - * which also spells `deflate`: the `fetch` transport offers no seam to apply that one. - */ -export type SupportedCompression = 'gzip' | 'none' ``` -依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterConfigBase`(`@opentelemetry/otlp-exporter-base`) +依赖:`BatchLogRecordProcessorOptions`(`@opentelemetry/sdk-logs`)· `OTLPExporterNodeConfigBase`(`@opentelemetry/otlp-exporter-base`) -来源:[`packages/session/session-telemetry-otel/src/index.ts:96`](../packages/session/session-telemetry-otel/src/index.ts) +来源:[`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts) diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml index 20d892bae1..f5ed5f8d78 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: 3561ec5b0dfc4290ab29dfe66fc91b19031fa31d -network-proxy.zh.md: 928f67db215650f2761ae5c929de3605b76b2520 +network-proxy.md: 127ee0f2c296d29a9ddd6e8b0f041fca4de4b394 +network-proxy.zh.md: a6efbd32bed7cb07b9e75e71d03b4ca876fc384d diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md index 3561ec5b0d..127ee0f2c2 100644 --- a/docs/user/guide/network-proxy.md +++ b/docs/user/guide/network-proxy.md @@ -67,6 +67,7 @@ Not every request DSH makes goes through the proxy: - **Anything on this machine.** Loopback is always direct: `localhost`, the whole `127.0.0.0/8` range, `::1`, and `0.0.0.0`. A proxy cannot usefully reach a service that only listens locally. - **Code the model writes.** The workflow and code-runtime workers never receive the proxy settings, so a script the model authors cannot read a proxy URL that may carry a password. Such a script reaches the network only if it configures that itself. +- **Usage telemetry.** The OTLP exporter uses Node's own HTTP client rather than the one a proxy configures, so telemetry connects directly and simply fails where direct egress is blocked. Nothing you do in DSH depends on it. Set `DSH_TELEMETRY_MODE=DISABLED` to turn it off entirely. - **`web_fetch` to a literal private address.** A URL naming an address like `http://10.0.0.5/` is refused rather than handed to the proxy, the same refusal it gets with no proxy configured. ## Check that it worked diff --git a/docs/user/guide/network-proxy.zh.md b/docs/user/guide/network-proxy.zh.md index 928f67db21..a6efbd32be 100644 --- a/docs/user/guide/network-proxy.zh.md +++ b/docs/user/guide/network-proxy.zh.md @@ -67,6 +67,7 @@ Node 只在进程启动时读取该变量,所以要在运行 `dsh` 之前导 - **本机上的一切。** loopback 始终直连:`localhost`、整个 `127.0.0.0/8` 段、`::1` 与 `0.0.0.0`。代理无法有意义地访问一个只在本地监听的服务。 - **模型编写的代码。** workflow 与 code-runtime worker 从不接收代理配置,因此模型编写的脚本读不到可能携带密码的代理 URL。这类脚本只有自行配置才能联网。 +- **使用情况遥测。** OTLP 导出器用的是 Node 自带的 HTTP 客户端,而不是代理所配置的那个,因此遥测直连;在禁止直连出网的环境里它只会失败。DSH 的任何功能都不依赖它。设 `DSH_TELEMETRY_MODE=DISABLED` 可完全关闭。 - **`web_fetch` 访问字面量私网地址。** 形如 `http://10.0.0.5/` 的 URL 会被拒绝而非交给代理,与未配置代理时得到的拒绝相同。 ## 验证是否生效 diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 65e698b411..3c7ac1f320 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -29,11 +29,11 @@ "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/api-logs": "^0.220.0", + "@opentelemetry/exporter-logs-otlp-http": "^0.220.0", "@opentelemetry/otlp-exporter-base": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", - "@deepseek-ai/schemastery": "workspace:^", - "@opentelemetry/otlp-transformer": "^0.220.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { "@deepseek-ai/dsh-command-feedback": "workspace:^", @@ -41,8 +41,7 @@ "@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-http-proxy": "workspace:^" + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-logger-console": "workspace:^", diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index ad84f0b5cc..c88d442c87 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -13,7 +13,6 @@ */ import { createRequire } from 'node:module' -import { gzipSync } from 'node:zlib' import z from '@deepseek-ai/schemastery' import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-command-feedback' @@ -32,12 +31,8 @@ import { LoggerProvider, type BatchLogRecordProcessorOptions, } from '@opentelemetry/sdk-logs' -import { OTLPExporterBase } from '@opentelemetry/otlp-exporter-base' -import { createLegacyOtlpBrowserExportDelegate } from '@opentelemetry/otlp-exporter-base/browser-http' -import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer' -import type { ISerializer } from '@opentelemetry/otlp-transformer' -import type { OTLPExporterConfigBase } from '@opentelemetry/otlp-exporter-base' -import type { ReadableLogRecord } from '@opentelemetry/sdk-logs' +import { OTLPLogExporter } from '@opentelemetry/exporter-logs-otlp-http' +import type { OTLPExporterNodeConfigBase } from '@opentelemetry/otlp-exporter-base' import { SeverityNumber, type AnyValue, type Logger } from '@opentelemetry/api-logs' import { resourceFromAttributes } from '@opentelemetry/resources' @@ -98,20 +93,13 @@ export interface Config { mode?: SessionTelemetryMode /** * Passed verbatim to the SDK's OTLP/HTTP log exporter — the complete - * `OTLPExporterConfigBase` shape (`headers`, `timeoutMillis`, - * `concurrencyLimit`, …), owned and documented by the SDK. `url` is the - * one field this package requires and validates itself. - * - * The transport is the SDK's `fetch` one, so `keepAlive` and - * `httpAgentOptions` — which configure its `node:http` transport — are - * refused at load rather than ignored. `compression` is honored by this - * package instead of by that transport. + * `OTLPExporterNodeConfigBase` shape (`headers`, `timeoutMillis`, + * `compression`, `keepAlive`, …), owned and documented by the SDK. `url` + * is the one field this package requires and validates itself. */ - exporter?: OTLPExporterConfigBase & { + exporter?: OTLPExporterNodeConfigBase & { /** Full logs endpoint (e.g. `https://collector.example.com/v1/logs`). Required outside `DISABLED`; validated at load. */ url?: string - /** Request body compression, applied by this package rather than by the SDK transport. @default 'none' */ - compression?: SupportedCompression } /** * Passed verbatim to `BatchLogRecordProcessor` (minus the exporter slot, @@ -143,50 +131,6 @@ export const DEFAULT_SHUTDOWN_TIMEOUT_MILLIS = 3_000 // protocol limit, not a deployment default. const MAX_TIMER_DELAY_MILLIS = 2_147_483_647 -/** - * Exporter options the SDK defines only for its `node:http` transport. They reach the `fetch` - * transport this package uses, which silently ignores every one of them. - */ -const NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS = ['keepAlive', 'httpAgentOptions'] as const - -/** The one encoding {@link gzipSerializer} applies, spelled as the OTLP `Content-Encoding` spells it. */ -const GZIP = 'gzip' - -/** - * Request body encodings this package applies. Narrower than the SDK's `CompressionAlgorithm`, - * which also spells `deflate`: the `fetch` transport offers no seam to apply that one. - */ -export type SupportedCompression = 'gzip' | 'none' - -/** {@link SupportedCompression} as values, for the load-time check on a configuration typed `any`. */ -const SUPPORTED_COMPRESSION: readonly string[] = [GZIP, 'none'] satisfies SupportedCompression[] - -/** - * Wrap a serializer so every batch it produces is gzipped. - * - * The SDK compresses in its `node:http` transport, which the `fetch` transport this package uses - * does not have; serialization is the one seam before the body reaches that transport. The shipped - * profile enables gzip, and a realistic batch measures over six times smaller with it, so dropping - * compression to gain proxy support would trade one deployment's problem for every deployment's. - * - * `gzipSync` runs on the export path, but a batch is bounded by `maxExportBatchSize` and exports are - * already off the request path — the batch processor schedules them. - * - * @param serializer - the SDK serializer producing the uncompressed request body. - * @returns a serializer producing the gzipped body, deserializing responses unchanged. - */ -function gzipSerializer(serializer: ISerializer): ISerializer { - return { - ...serializer, - serializeRequest: (request) => { - const serialized = serializer.serializeRequest(request) - // The SDK returns nothing for a batch it could not serialize. Gzipping that would post an - // empty frame the collector accepts as a valid, empty export. - return serialized === undefined ? undefined : gzipSync(serialized) - }, - } -} - /** Severity mapping from the Service Definition's three-level vocabulary to OTel severity numbers. */ const SEVERITY: Record = { info: { severityNumber: SeverityNumber.INFO, severityText: 'INFO' }, @@ -223,8 +167,7 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { return } - const exporter = config.exporter ?? {} - const url = exporter.url + const url = config.exporter?.url if (url === undefined || url.length === 0) { throw new Error('session-telemetry-otel: exporter.url is required (the full OTLP logs endpoint)') } @@ -238,20 +181,6 @@ export class OpenTelemetrySessionBackend extends SessionTelemetryBackend { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error(`session-telemetry-otel: exporter.url must be http(s), got ${parsed.protocol}`) } - // `keepAlive` and `httpAgentOptions` configure the SDK's `node:http` transport, which this - // package does not use — the `fetch` transport is what reaches a configured proxy. The exporter - // would accept and ignore them, so a deployment would believe it had tuned a connection it had - // not. `compression` is the third such option and is honored instead of refused, below. - const nodeOnly = NODE_TRANSPORT_ONLY_EXPORTER_OPTIONS.filter(name => name in exporter) - if (nodeOnly.length > 0) { - throw new Error(`session-telemetry-otel: exporter.${nodeOnly.join(', exporter.')} not supported: telemetry is exported through fetch, whose connections Node owns; ${nodeOnly.length === 1 ? 'that option belongs' : 'those options belong'} to the node:http transport this package no longer builds an agent for`) - } - // Compared as strings because that is what arrives: the schema validates this object as `any`, - // so a cordis.yml may name any algorithm, including one the SDK's enum does not spell. - const compression: string = exporter.compression ?? 'none' - if (!SUPPORTED_COMPRESSION.includes(compression)) { - throw new Error(`session-telemetry-otel: exporter.compression must be one of ${SUPPORTED_COMPRESSION.map(value => JSON.stringify(value)).join(', ')}, got ${JSON.stringify(compression)}`) - } // The one processor field checked beyond the SDK's own validation: the // SDK accepts a non-positive batch size, but its shutdown drain then // splices empty batches without consuming the queue — dispose would hang @@ -283,32 +212,7 @@ 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. - // - // The delegate is the SDK's `fetch` one rather than its Node `node:http` one. Both are - // published entry points of the same package; the `fetch` transport reaches undici's - // global dispatcher, so a configured proxy carries telemetry with no proxy-aware code - // here and with no Node-version floor. The Node transport would need an `http.Agent`, - // and Node only learned to route one from the environment in 22.21 and 24.5. - // - // What that costs: `compression` is a Node-transport option and has no effect here. - // - // The delegate is deprecated in favour of `createOtlpFetchExportDelegate`, which the SDK - // exports from no public subpath at 0.220 — this legacy wrapper is the only supported way - // to reach it, and does nothing but call it. Composing the public - // `createOtlpNetworkExportDelegate` instead would mean owning the fetch transport and its - // retry wrapper, both SDK-internal. - exporter: new OTLPExporterBase( - // oxlint-disable-next-line typescript/no-deprecated -- the SDK exports its replacement from no public subpath at 0.220. - createLegacyOtlpBrowserExportDelegate( - exporter, - compression === GZIP ? gzipSerializer(JsonLogsSerializer) : JsonLogsSerializer, - 'v1/logs', - { - 'Content-Type': 'application/json', - ...compression === GZIP ? { 'Content-Encoding': GZIP } : {}, - }, - ), - ), + exporter: new OTLPLogExporter(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 da8342f125..229b5f3fb4 100644 --- a/packages/session/session-telemetry-otel/tests/egress.spec.ts +++ b/packages/session/session-telemetry-otel/tests/egress.spec.ts @@ -1,7 +1,13 @@ -import http, { createServer, type Server } from 'node:http' +import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' +import { Context } from '@deepseek-ai/cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import OpenTelemetrySessionBackend, { SessionTelemetryMode } from '../src/index.ts' let seen: string[] = [] let proxy: Server @@ -21,23 +27,6 @@ beforeAll(async () => { }) afterAll(async () => { await new Promise((r) => { proxy.close(() => { r() }) }) }) -/** The launch environment of a user who exported one proxy for both schemes. */ -function proxyEnv(): { get(name: string): { value: string } | undefined } { - return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } -} -async function observe(run: () => Promise): Promise { - seen = [] - const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) - 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(() => { @@ -51,13 +40,18 @@ afterAll(() => { rmSync(home, { recursive: true, force: true }) }) +/** The launch environment of a user who exported one proxy for both schemes. */ +function proxyEnv(): { get(name: string): { value: string } | undefined } { + return { get: name => (name === 'HTTP_PROXY' || name === 'HTTPS_PROXY' ? { value: proxyUrl } : undefined) } +} + /** Mount the shipping backend against an unresolvable collector and let it try to export. */ -async function exportThroughBackend(host: string, exporter: Record = {}): Promise { +async function exportThroughBackend(host: string): Promise { const ctx = new Context() await ctx.plugin(SessionStore) const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, - exporter: { url: `http://${host}/v1/logs`, ...exporter }, + exporter: { url: `http://${host}/v1/logs` }, }) const session = ctx.sessions.create(SessionId('egress'), { meta: { cwd: '/tmp/e' } }) session.append('turn/start', { turn: 1 }) @@ -65,25 +59,24 @@ async function exportThroughBackend(host: string, exporter: Record { - it('exports through the proxy', async () => { - const observed = (await observe(() => exportThroughBackend('otel-proxied.invalid'))).join('|') - // No runtime gate: the exporter posts through `fetch`, which resolves undici's global - // dispatcher on every Node this repository supports. The SDK's own `node:http` transport would - // have needed `http.Agent`'s `proxyEnv`, which arrived in 22.21 and 24.5 — inside the engines - // range, so telemetry would have stayed direct on 22.19, 22.20, and 24.0–24.4. - expect(observed).toContain('otel-proxied.invalid') - }) - - it('reaches no proxy over node:http — the transport this exporter no longer uses', async () => { - const observed = await observe(() => new Promise((resolve) => { - // The mechanism behind the case above, asserted rather than described: a global dispatcher is - // undici's, and `node:http` never consults it. An exporter built on the SDK's Node transport - // would take this path and leave telemetry direct however the proxy is configured. - http.get('http://otel-direct.invalid/v1/logs', (response) => { response.resume(); resolve() }) - .on('error', () => { resolve() }) - })) - expect(observed.join('|')).not.toContain('otel-direct.invalid') + it('exports directly, ignoring a configured proxy', async () => { + seen = [] + const dispose = await installProxyFromEnvironment(proxyEnv(), () => undefined) + try { + await exportThroughBackend('otel-direct.invalid').catch(() => undefined) + } finally { + await dispose() + } + // Telemetry is the one outbound path this repository deliberately leaves direct. The SDK's OTLP + // exporter posts through `node:http`, which no global dispatcher reaches, and routing it would + // mean either an `http.Agent` whose `proxyEnv` option arrives after this project's lowest + // supported Node, or replacing the transport and reimplementing the compression the shipped + // profile enables. Neither is worth it for a channel whose loss costs the user nothing. + // + // This case exists so that stays a decision: an SDK upgrade that moved the exporter onto + // `fetch` would start routing telemetry through a proxy silently, and this assertion is what + // makes that visible instead. + expect(seen).toEqual([]) }) }) diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 4563bc7475..0888cf2d05 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -12,7 +12,6 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { gunzipSync } from 'node:zlib' -import { JsonLogsSerializer } from '@opentelemetry/otlp-transformer' import { Context } from '@deepseek-ai/cordis' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' import Loader from '@deepseek-ai/cordis-plugin-loader' @@ -238,111 +237,24 @@ describe('OpenTelemetrySessionBackend wire', () => { const { url, captures } = await mockCollector() const ctx = new Context() await ctx.plugin(SessionStore) - // `headers` is a documented SDK exporter option this package neither reads nor rebuilds; the - // advertised verbatim passthrough must hand it (and every other field) to the exporter rather - // than silently rebuilding url only. - const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { - mode: SessionTelemetryMode.FULL, - exporter: { url, headers: { 'x-probe': 'passthrough' } }, - }) - const session = ctx.sessions.create(SessionId('passthrough'), { meta: {} }) - session.append('turn/start', { turn: 1 }) - await fiber.dispose() - - expect(captures.length).toBeGreaterThan(0) - expect(captures[0]!.headers['x-probe']).toBe('passthrough') - const types = allRecords(captures).flatMap(({ record }) => - record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? []) - expect(types).toContain('turn/start') - }) - - it('gzips the batch when the shipped profile asks for it', async () => { - const { url, captures } = await mockCollector() - const ctx = new Context() - await ctx.plugin(SessionStore) - // The shipped `base` bundle sets this, and a realistic batch is over six times smaller with it. - // The SDK compresses in its `node:http` transport, which the `fetch` transport used here does - // not have, so this package gzips at the serializer and declares the encoding itself. + // `compression` is a documented SDK exporter option; the advertised + // verbatim passthrough must hand it (and every other field) to the + // exporter rather than silently rebuilding url/headers only. const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, exporter: { url, compression: 'gzip' }, - }) + } as Config) const session = ctx.sessions.create(SessionId('gzip'), { meta: {} }) session.append('turn/start', { turn: 1 }) await fiber.dispose() expect(captures.length).toBeGreaterThan(0) expect(captures[0]!.headers['content-encoding']).toBe('gzip') - // The collector gunzips the body it received, so the header is not merely asserted alongside a - // plaintext payload the encoding would have misdescribed. const types = allRecords(captures).flatMap(({ record }) => record.attributes?.flatMap(a => a.key === 'event.type' ? [a.value.stringValue] : []) ?? []) expect(types).toContain('turn/start') }) - it('sends the batch uncompressed when no compression is configured', async () => { - const { url, captures } = await mockCollector() - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { mode: SessionTelemetryMode.FULL, exporter: { url } }) - ctx.sessions.create(SessionId('plain'), { meta: {} }).append('turn/start', { turn: 1 }) - await fiber.dispose() - - expect(captures.length).toBeGreaterThan(0) - expect(captures[0]!.headers['content-encoding']).toBeUndefined() - }) - - it('sends nothing when the SDK cannot serialize the batch, rather than an empty gzip frame', async () => { - const { url, captures } = await mockCollector() - const serialize = vi.spyOn(JsonLogsSerializer, 'serializeRequest').mockReturnValue(undefined) - try { - const ctx = new Context() - await ctx.plugin(SessionStore) - const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { - mode: SessionTelemetryMode.FULL, - exporter: { url, compression: 'gzip' }, - }) - ctx.sessions.create(SessionId('unserializable'), { meta: {} }).append('turn/start', { turn: 1 }) - await fiber.dispose() - expect(serialize).toHaveBeenCalled() - expect(captures).toEqual([]) - } finally { - serialize.mockRestore() - } - }) - - it('names every node:http option a configuration set, not just the first', async () => { - const { url } = await mockCollector() - const ctx = new Context() - await ctx.plugin(SessionStore) - await expect(ctx.plugin(OpenTelemetrySessionBackend, { - mode: SessionTelemetryMode.FULL, - exporter: { url, keepAlive: true, httpAgentOptions: {} }, - } as unknown as Config)).rejects.toThrow(/exporter\.keepAlive, exporter\.httpAgentOptions not supported/) - }) - - it('refuses an exporter option that belongs to the node:http transport', async () => { - const { url } = await mockCollector() - const ctx = new Context() - await ctx.plugin(SessionStore) - // `keepAlive` tunes a `node:http` connection pool this package no longer builds. Accepting it - // would let a deployment believe it had tuned a connection that does not exist. - await expect(ctx.plugin(OpenTelemetrySessionBackend, { - mode: SessionTelemetryMode.FULL, - exporter: { url, keepAlive: true }, - } as unknown as Config)).rejects.toThrow(/exporter\.keepAlive not supported/) - }) - - it('refuses a compression algorithm it cannot apply', async () => { - const { url } = await mockCollector() - const ctx = new Context() - await ctx.plugin(SessionStore) - await expect(ctx.plugin(OpenTelemetrySessionBackend, { - mode: SessionTelemetryMode.FULL, - exporter: { url, compression: 'deflate' }, - } as unknown as Config)).rejects.toThrow(/exporter\.compression must be one of "gzip", "none"/) - }) - it('maps warn severity from record policy and leaves the seam flush hint unimplemented', async () => { const { url, captures } = await mockCollector() const { ctx, fiber } = await boot(url) diff --git a/packages/session/session-telemetry-otel/tsconfig.json b/packages/session/session-telemetry-otel/tsconfig.json index 30e5bd02bf..806fdabf18 100644 --- a/packages/session/session-telemetry-otel/tsconfig.json +++ b/packages/session/session-telemetry-otel/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../identity/anonymous-user-id" - }, - { - "path": "../../util/http-proxy" } ] } diff --git a/packages/util/http-proxy/README.i18n.yaml b/packages/util/http-proxy/README.i18n.yaml index f496bbafc0..51f6c0f4ab 100644 --- a/packages/util/http-proxy/README.i18n.yaml +++ b/packages/util/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/util/http-proxy/README.md -README.md: 2bad73fa7ab670d73adab6e9a82602b0fa374752 -README.zh.md: 85f49c2e33d2064f49acba9df2c935d81c36b175 +README.md: ffb3b209bbdb19810e1125ec8cd6ce6c01376a8c +README.zh.md: fce5b914a0854ed723ae71c7e779acff70434c9e diff --git a/packages/util/http-proxy/README.md b/packages/util/http-proxy/README.md index 2bad73fa7a..ffb3b209bb 100644 --- a/packages/util/http-proxy/README.md +++ b/packages/util/http-proxy/README.md @@ -9,7 +9,7 @@ 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. Four functions cover everything the global dispatcher cannot reach on its own — install the policy, ask where one request goes, hand the policy to a spawned child, and strip it for a replay. +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, 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. Four functions cover everything the global dispatcher cannot reach on its own — install the policy, ask where one request goes, hand the policy to a spawned child, and strip it for a replay. ## Table of Contents @@ -41,11 +41,11 @@ Plain `fetch()` is proxied, and so is any SDK that reaches `globalThis.fetch` `proxyRouteFor` answers with the transport that answer assumed, not just the answer: its proxied arm carries the dispatcher already routing by this policy. A caller that read the policy and then built its own transport could have an unmount land between the two and send the request somewhere its branch never cleared. -An SDK that builds its own transport reaches none of this. The two this repository ships that did — the OTLP exporter and the E2B SDK — were changed to a transport that does: the exporter now posts through `fetch`, and E2B is handed `route.proxy`. +An SDK that builds its own transport reaches none of this, and two of the ones this repository ships do. E2B takes a proxy URL of its own and is handed `route.proxy`. The OTLP telemetry exporter posts through `node:http`, and is deliberately left direct — see the limitation below. Constructing `new Agent(...)` and passing it as `dispatcher` overrides the global one and silently bypasses the proxy. `verify-no-bare-dispatcher` rejects that outside this package. One call site legitimately owns its transport — `web-fetch-http` pins a request to addresses it validated, which is per-request state a process-wide dispatcher cannot hold — and says so with a `proxy-exempt:` comment on the line. -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. +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 — or, for telemetry, that it did not. A new call site adds one. It is the only thing that catches an SDK changing transports underneath us, in either direction: it is how the OTLP and E2B gaps were found, and it is what would catch an upgrade that started routing telemetry silently. ### What the policy reads @@ -109,6 +109,7 @@ These limits define when the package is a poor fit. They are current package con - **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment. A macOS or Windows system-proxy setting is not read, so a user who only toggled it in a proxy application must still export the variables; a SOCKS URL is reported and that scheme stays direct rather than borrowing another scheme's proxy. - **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 spawned child honors the policy only on a new enough runtime** — it reads the published environment through Node's `NODE_USE_ENV_PROXY` (22.21+, 24+), and the engines range admits 22.19 and 22.20, where such a child stays direct. A child also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in their separators and IPv4-range support. Nothing in this process depends on a Node version: every in-process request reaches the global dispatcher. +- **Telemetry is direct by design** — the OTLP exporter posts through `node:http`, which no global dispatcher reaches. Routing it would need either an `http.Agent` whose `proxyEnv` option post-dates the lowest supported Node, or the SDK's `fetch` transport, which has no compression while the shipped profile enables gzip. Telemetry is the one channel whose loss costs the user nothing, so it stays where it was; `DSH_TELEMETRY_MODE=DISABLED` turns it off. - **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`. diff --git a/packages/util/http-proxy/README.zh.md b/packages/util/http-proxy/README.zh.md index 85f49c2e33..fce5b914a0 100644 --- a/packages/util/http-proxy/README.zh.md +++ b/packages/util/http-proxy/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代理后面运行的 Harness 无论用户导出了什么都会直连——LLM(大语言模型)请求、每次 web 搜索、走 HTTP 的 MCP、遥测与沙箱 SDK 一概如此。本包从启动器的环境快照解析出一份代理策略,并把它装成 undici 的全局 dispatcher,而这正是 `fetch` 解析的对象。因此普通调用点无需改动、也无需引入本包:写 `fetch()` 就已经走代理。全局 dispatcher 自身够不到的场合由四个函数覆盖——安装策略、询问某个请求怎么发、把策略交给派生的子进程、以及为重放清掉它。 +Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代理后面运行的 Harness 无论用户导出了什么都会直连——LLM(大语言模型)请求、每次 web 搜索、走 HTTP 的 MCP 与沙箱 SDK 一概如此。本包从启动器的环境快照解析出一份代理策略,并把它装成 undici 的全局 dispatcher,而这正是 `fetch` 解析的对象。因此普通调用点无需改动、也无需引入本包:写 `fetch()` 就已经走代理。全局 dispatcher 自身够不到的场合由四个函数覆盖——安装策略、询问某个请求怎么发、把策略交给派生的子进程、以及为重放清掉它。 ## 目录 @@ -41,11 +41,11 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代 `proxyRouteFor` 给出的不只是答案,还有该答案所假定的传输:走代理的那一支携带着此刻正按该策略路由的 dispatcher。若调用方先读策略、再自建传输,卸载就可能落在两次读取之间,把请求发往其分支从未放行的去处。 -自建传输的 SDK 接触不到上述任何一条。本仓库随附的两个此类 SDK 都已改到能被覆盖的传输上:OTLP 导出器改为通过 `fetch` 投递,E2B 则接收 `route.proxy`。 +自建传输的 SDK 接触不到上述任何一条,而本仓库随附的 SDK 里有两个如此。E2B 接受自有代理 URL,现在接收 `route.proxy`。OTLP 遥测导出器通过 `node:http` 投递,被有意保留为直连——见下方限制一节。 构造 `new Agent(...)` 再作为 `dispatcher` 传入会覆盖全局 dispatcher,从而静默绕开代理。`verify-no-bare-dispatcher` 会在本包之外拒绝该写法。有一处调用点确实自有传输——`web-fetch-http` 会把请求钉在它已校验过的地址上,而这是进程级 dispatcher 无法承载的单次请求状态——它在该行用 `proxy-exempt:` 注释说明。 -该门禁看不进 SDK 内部,因此仓库中每一个出网点都另有一份 `egress.spec.ts`:它驱动该点的真实代码路径穿过一个假代理,并断言代理确实收到了请求。新增出网点就补一份。它是唯一能发现 SDK 在我们脚下更换传输的手段——OTLP 与 E2B 这两个漏洞正是这样被发现的。 +该门禁看不进 SDK 内部,因此仓库中每一个出网点都另有一份 `egress.spec.ts`:它驱动该点的真实代码路径穿过一个假代理,并断言代理确实收到了请求——遥测那份则断言代理什么也没收到。新增出网点就补一份。它是唯一能双向发现 SDK 在我们脚下更换传输的手段:OTLP 与 E2B 这两个漏洞正是这样被发现的,而某次升级若开始静默地把遥测送去代理,也由它拦下。 ### 策略读取哪些值 @@ -109,6 +109,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` - **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告,且该协议保持直连,不会借用另一协议的代理。 - **不支持自定义证书颁发机构**——做 TLS 拦截的企业代理需要在启动前为进程设置 `NODE_EXTRA_CA_CERTS`,本包既不设置也不校验它。 - **派生的子进程只在足够新的运行时上遵循策略**——它通过 Node 的 `NODE_USE_ENV_PROXY` 读取已发布的环境(22.21+、24+),而 engines 范围允许 22.19 与 22.20,在这两个版本上这样的子进程保持直连。子进程还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。本进程内不依赖任何 Node 版本:每一次进程内请求都会落到全局 dispatcher。 +- **遥测按设计直连**——OTLP 导出器通过 `node:http` 投递,全局 dispatcher 触及不到。要让它走代理,要么依赖 `http.Agent` 的 `proxyEnv`,而该选项晚于本项目支持的最低 Node 版本;要么改用 SDK 的 `fetch` 传输,但它没有压缩能力,而随附配置启用了 gzip。遥测是唯一一条丢失了对用户毫无代价的通道,因此维持原状;`DSH_TELEMETRY_MODE=DISABLED` 可关闭它。 - **执行模型编写代码的 worker 完全不获得代理**——`code-runtime` worker 与 `workflow` worker 都不接收代理配置,它们自身的请求直连。代理 URL 可能携带 `user:password`,而两者运行的都是模型写的脚本。 - **防回归门禁只看源码,看不到依赖内部**——`verify-no-bare-dispatcher` 解析 `packages/*/*/src` 与 `apps/*/src`;测试、脚本以及第三方 SDK 的内部都在其之外。这正是每个出网点还各配一份 `egress.spec.ts` 的原因。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe1b8539d8..b48adda7be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7233,10 +7233,10 @@ importers: '@opentelemetry/api-logs': specifier: ^0.220.0 version: 0.220.0 - '@opentelemetry/otlp-exporter-base': + '@opentelemetry/exporter-logs-otlp-http': specifier: ^0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) - '@opentelemetry/otlp-transformer': + '@opentelemetry/otlp-exporter-base': specifier: ^0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) '@opentelemetry/resources': @@ -11773,6 +11773,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/exporter-logs-otlp-http@0.220.0': + resolution: {integrity: sha512-8186thl+pTw64iz/qEEen5oJZoZ/gO73XruChdaGlYdWOdBIQ42r+vHLf6a7vIDqTD4b8ZOoMlyxptanECaI9A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-exporter-base@0.220.0': resolution: {integrity: sha512-CXYo8UD5Mn9YbgebO2EL4wejtA+gxLmLiu6HCk2KH2BR7XhFN6/6p1UlCb23DYCjeYkndevLHuejCCN1yx4+OQ==} engines: {node: ^18.19.0 || >=20.6.0} @@ -17372,6 +17378,15 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/exporter-logs-otlp-http@0.220.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.220.0 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.220.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base@0.220.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 From 0d99bd04895c9489b6d2c48300c0cbd306579deb Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 1 Sep 2026 21:59:50 +0800 Subject: [PATCH 19/43] docs: regenerate the module graph without the telemetry proxy edge Reverting telemetry to its own transport dropped the package's dependency on the proxy library, and the generated graph still recorded that edge. The gate that catches this is `verify-module-graph`, which runs in `check:ci:static` and not in `doc-sync`; the revert was checked with the latter alone. --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 3 +-- docs/module-graph.zh.md | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 6ee9282fe5..1723cfd59c 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: 157298dfc2eb5a38cbb87aa1cb9960c9d272e73b -module-graph.zh.md: 65678a4b4c0036b18d275bbaf87443a053dc11c3 +module-graph.md: fd876bb0bc4645f10d1d5ae06d32fee217805ea6 +module-graph.zh.md: d2e5e1abf8d78f25bf90b3710a787238bbd397f1 diff --git a/docs/module-graph.md b/docs/module-graph.md index 157298dfc2..fd876bb0bc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -829,7 +829,6 @@ 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_llm pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry @@ -1350,7 +1349,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), [`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), [`http-proxy`](../packages/util/http-proxy), [`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), [`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` | [`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` | [`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), [`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 65678a4b4c..d2e5e1abf8 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -831,7 +831,6 @@ 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_llm pkg_session_telemetry_otel --> pkg_session pkg_session_telemetry_otel --> pkg_session_telemetry @@ -1352,7 +1351,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), [`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), [`http-proxy`](../packages/util/http-proxy), [`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), [`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` | [`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` | [`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), [`session-persistence`](../packages/session/session-persistence), [`shell`](../packages/shell/shell), [`tools`](../packages/core/tools) | From 279594032380b0144c4afd8a16c1ed89ec8c3cf0 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 09:43:47 +0800 Subject: [PATCH 20/43] docs(http-proxy): state the shipped library, not the retired plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the package's prose still describing a plugin that an earlier revision removed, and three factual slips about behavior. - policy.ts, install.ts, install.spec.ts, and the Agent Note named a `Config` surface, a `cordis.yml` source, a mountable plugin, and a `plugin.spec.ts` that no longer exist; each now describes the environment-only resolution the launcher actually runs. - `NO_PROXY=example.com` bypasses `api.example.com` as well — the matcher accepts the host and every subdomain under it, and a leading `.` or `*.` means the same thing. The guide, README, and JSDoc claimed a bare entry matched only the exact host, which would let a reader believe a subdomain was proxied when it went direct. - A rejection diagnostic names the variable and never its value, so no username is shown; the guide said the username was shown with the rest masked. The README's source map claimed a "redaction" step that does not exist. - The `node:https` worker placeholder was added for a `node:http` agent factory this PR later removed; nothing imports `node:https` now, so the stub, its VFS mapping, and its test return to their state on master. --- ...2026-08-27-outbound-proxy-policy.i18n.yaml | 4 +- .../2026-08-27-outbound-proxy-policy.md | 6 +-- .../2026-08-27-outbound-proxy-policy.zh.md | 6 +-- docs/user/guide/network-proxy.i18n.yaml | 4 +- docs/user/guide/network-proxy.md | 4 +- docs/user/guide/network-proxy.zh.md | 4 +- .../webworker-runtime/src/module-proxies.ts | 1 - .../src/node/builtin_modules/mock/https.ts | 49 ------------------- .../tests/node/node-stubs.spec.ts | 16 ------ packages/util/http-proxy/README.i18n.yaml | 4 +- packages/util/http-proxy/README.md | 4 +- packages/util/http-proxy/README.zh.md | 4 +- packages/util/http-proxy/src/install.ts | 10 ++-- packages/util/http-proxy/src/policy.ts | 14 +++--- .../util/http-proxy/tests/install.spec.ts | 6 +-- 15 files changed, 36 insertions(+), 100 deletions(-) delete mode 100644 packages/experimental/webworker-runtime/src/node/builtin_modules/mock/https.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 d379de0f0f..f8f7fa9937 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: a35b8a909eaa1526d3268e475de4d3f7e093b6eb -2026-08-27-outbound-proxy-policy.zh.md: 278d86faa186c36a289eb477f8b741d46a1dfb0c +2026-08-27-outbound-proxy-policy.md: 0de49f2c3302cfdc611d757828390eb8ef8efe84 +2026-08-27-outbound-proxy-policy.zh.md: 59175834f5257dd3065debffdda97d5fc9645c04 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 a35b8a909e..0de49f2c33 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 @@ -36,7 +36,7 @@ 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, 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. +**Rejection is quiet, 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 is reported on stderr and skipped — the variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The environment is the only source, so no configuration surface exists where `AGENTS.md`'s fail-loud rule would apply instead. **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. @@ -74,7 +74,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the ## 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. +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. The launcher installs it exactly once, before the first plugin mounts. 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. @@ -82,7 +82,7 @@ Because the operating system's settings are not read, the user-facing documentat 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. +The suite is hermetic against the developer's own environment: every Vitest configuration runs `scripts/test-proxy-environment.ts`, which clears all eight proxy names in both casings before any test, and `install.spec.ts` restores the machine's values around each case that sets its own. It has to. An exported lowercase `all_proxy` decided a test's outcome during development, because resolution reads lowercase first. ## Testing 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 278d86faa1..59175834f5 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 @@ -36,7 +36,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 **解析补上 Node 与 undici 都不提供的部分。** `ALL_PROXY` 为两种协议兜底;空值视为未设置,因为 undici 的 `??` 链会让空的小写名遮住有值的大写名;loopback 始终绕过,否则 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。绕过列表同时携带 `::1` **与** `[::1]`:undici 自带的匹配器会把裸写的 `::1` 读成主机 `:` 端口 `1`,从而永不豁免它。 -**拒绝是响还是静取决于值从哪来,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。来自**环境**的 SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。同样的值若经由插件的 `Config` 传入,则在加载期抛出,因为那是 Harness 自己的配置面,`AGENTS.md` 要求配置错误必须响。 +**拒绝是静默的,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。环境是唯一来源,因此不存在一个本应适用 `AGENTS.md` 「配置错误必须响」规则的配置面。 **经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。 @@ -74,7 +74,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 ## Consequences -导出了 `HTTPS_PROXY`、或把它写进 `.env` 层的用户,在 Harness 发起请求的每一处都会走代理,无需任何标志与配置。希望把策略写进 `cordis.yml` 的组合可挂载该插件;它不在任何随附组合包中,因此默认路径只安装一次。 +导出了 `HTTPS_PROXY`、或把它写进 `.env` 层的用户,在 Harness 发起请求的每一处都会走代理,无需任何标志与配置。启动器在第一个插件挂载之前恰好安装一次。 由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `docs/user/guide/network-proxy.md` 说明了要导出哪些变量,以及为什么浏览器走代理而终端不走——这个「三套机制」的困惑是最常见的报障,且并非本 Harness 特有。 @@ -82,7 +82,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合而非约定——corepack#834 记录了它失效的实例——因此 `tests/install.spec.ts` 会驱动一次真实请求穿过 loopback 代理。破坏该耦合的版本升级会在那里失败,而不是流到线上。 -测试套件对开发者自身的环境免疫:`plugin.spec.ts` 会保存并清除全部八个代理变量名的两种大小写形式。这是必需的。开发过程中,一个已导出的小写 `all_proxy` 曾决定了某个测试的结果,因为解析优先读取小写。 +测试套件对开发者自身的环境免疫:每份 Vitest 配置都会先运行 `scripts/test-proxy-environment.ts`,在任何测试之前清除全部八个代理变量名的两种大小写形式;`install.spec.ts` 则在每个自行设值的用例前后还原本机的值。这是必需的。开发过程中,一个已导出的小写 `all_proxy` 曾决定了某个测试的结果,因为解析优先读取小写。 ## Testing diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml index f5ed5f8d78..9a942a0b8f 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: 127ee0f2c296d29a9ddd6e8b0f041fca4de4b394 -network-proxy.zh.md: a6efbd32bed7cb07b9e75e71d03b4ca876fc384d +network-proxy.md: 896e4b5416910cf34bcbe5297382f71cf3d3e2bb +network-proxy.zh.md: 8283a5836223b843f51ffd0cc972b803703e1a33 diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md index 127ee0f2c2..896e4b5416 100644 --- a/docs/user/guide/network-proxy.md +++ b/docs/user/guide/network-proxy.md @@ -13,7 +13,7 @@ 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. +A proxy that needs credentials takes them in the URL: `http://user:password@proxy.example:8080`. DSH never prints the URL back: a diagnostic names the variable it rejected, so neither the username nor the password appears anywhere. ## Why your browser is proxied but your terminal is not @@ -37,7 +37,7 @@ DSH does not read the operating system's proxy settings. Export the variables, o 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. +An entry names a host and matches it together with every subdomain under it: `NO_PROXY=example.com` also sends `api.example.com` direct. A leading `.` or `*.` is accepted and means the same thing. An entry may carry a `:port`, and `*` bypasses 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. diff --git a/docs/user/guide/network-proxy.zh.md b/docs/user/guide/network-proxy.zh.md index a6efbd32be..8283a58362 100644 --- a/docs/user/guide/network-proxy.zh.md +++ b/docs/user/guide/network-proxy.zh.md @@ -13,7 +13,7 @@ export HTTP_PROXY=http://127.0.0.1:7890 把这两行写进 shell 配置,这样每次调用 `dsh` 都会继承它们。DSH 还会读取启动目录与 `$DSH_HOME` 下的 `.env` 文件,因此只对某个项目生效的代理可以写在那里;真实环境变量始终优先于文件。 -需要凭据的代理把凭据写在 URL 里:`http://user:password@proxy.example:8080`。DSH 绝不会回显密码——诊断信息中出现的代理会显示用户名并掩去其余部分。 +需要凭据的代理把凭据写在 URL 里:`http://user:password@proxy.example:8080`。DSH 绝不会回显这个 URL:诊断只点名被拒绝的变量,因此用户名和密码都不会出现在任何地方。 ## 为什么浏览器走代理、终端却不走 @@ -37,7 +37,7 @@ DSH 不读取操作系统的代理设置。请导出环境变量,或使用 TUN export NO_PROXY=internal.example.com,.corp.example.com,registry.local ``` -一个条目可匹配精确主机、`.suffix` 或 `*.suffix` 域名、可选的 `:port`,或用 `*` 匹配全部。 +一个条目写的是主机名,它连同其下所有子域名一起匹配:`NO_PROXY=example.com` 也会让 `api.example.com` 直连。前缀 `.` 或 `*.` 可以写,含义相同。条目可带 `:port`,`*` 则放行全部。 **CIDR 网段不生效。** 操作系统的绕过列表常含 `10.0.0.0/8` 或 `192.168.0.0/16` 这类条目;把它们复制进 `NO_PROXY` 不会有任何效果。请改用主机名或域名后缀。 diff --git a/packages/experimental/webworker-runtime/src/module-proxies.ts b/packages/experimental/webworker-runtime/src/module-proxies.ts index 3bd28f7430..3bb59ef366 100644 --- a/packages/experimental/webworker-runtime/src/module-proxies.ts +++ b/packages/experimental/webworker-runtime/src/module-proxies.ts @@ -41,7 +41,6 @@ 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 deleted file mode 100644 index 549c5390fb..0000000000 --- a/packages/experimental/webworker-runtime/src/node/builtin_modules/mock/https.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * `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 c83f656e32..ea4f1d02a8 100644 --- a/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts +++ b/packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts @@ -16,7 +16,6 @@ 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' @@ -129,21 +128,6 @@ 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]) diff --git a/packages/util/http-proxy/README.i18n.yaml b/packages/util/http-proxy/README.i18n.yaml index 51f6c0f4ab..b5a74ef1a5 100644 --- a/packages/util/http-proxy/README.i18n.yaml +++ b/packages/util/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/util/http-proxy/README.md -README.md: ffb3b209bbdb19810e1125ec8cd6ce6c01376a8c -README.zh.md: fce5b914a0854ed723ae71c7e779acff70434c9e +README.md: f0dab162eba192ee786e2eed9f19e5739f2d9ad1 +README.zh.md: acdd8200a3f304d9aa63127ba97652d1eadef0f4 diff --git a/packages/util/http-proxy/README.md b/packages/util/http-proxy/README.md index ffb3b209bb..f0dab162eb 100644 --- a/packages/util/http-proxy/README.md +++ b/packages/util/http-proxy/README.md @@ -72,13 +72,13 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri | File | Holds | |---|---| -| `src/policy.ts` | Resolution, bypass matching, and redaction. Imports no transport, so it stays loadable where undici is absent. | +| `src/policy.ts` | Resolution and bypass matching; a diagnostic names the variable, never its value. Imports no transport, so it stays loadable where undici is absent. | | `src/install.ts` | The global dispatcher, the active-policy record, the route, and the child environment. Imports undici dynamically. | | `src/index.ts` | The package face: four functions and one type. | ### 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. +An entry names a host and matches it together with every subdomain under it: `NO_PROXY=example.com` also bypasses `api.example.com`. A leading `.` or `*.` is accepted and means the same thing. An entry may carry a `:port`, and `*` bypasses everything. A bracketed or bare IPv6 literal matches either way — a bare `::1` is *not* read as host `:` port `1`, which is how undici's own matcher fails and why the resolved list carries both `::1` and `[::1]`. CIDR is not matched: an operating system's bypass list often carries `10.0.0.0/8`, which has to be rewritten as suffixes. ----- diff --git a/packages/util/http-proxy/README.zh.md b/packages/util/http-proxy/README.zh.md index fce5b914a0..acdd8200a3 100644 --- a/packages/util/http-proxy/README.zh.md +++ b/packages/util/http-proxy/README.zh.md @@ -72,13 +72,13 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` | 文件 | 承载 | |---|---| -| `src/policy.ts` | 解析、绕过匹配与脱敏。不引入任何传输实现,因此在没有 undici 的环境中仍可加载。 | +| `src/policy.ts` | 解析与绕过匹配;诊断只点名变量,从不带出它的值。不引入任何传输实现,因此在没有 undici 的环境中仍可加载。 | | `src/install.ts` | 全局 dispatcher、生效策略记录、路由与子进程环境。动态引入 undici。 | | `src/index.ts` | 本包的对外面:四个函数与一个类型。 | ### 绕过匹配 -一个条目可匹配精确主机、`.suffix` 或 `*.suffix` 域名、可选的 `:port`,或用 `*` 匹配全部。带方括号与裸写的 IPv6 字面量都能匹配——裸写的 `::1` **不会**被读成主机 `:` 端口 `1`,而 undici 自带的匹配器正是这样出错的,这也是解析结果中同时携带 `::1` 与 `[::1]` 的原因。CIDR 不参与匹配:操作系统的绕过列表常含 `10.0.0.0/8`,必须改写成后缀形式。 +一个条目写的是主机名,它连同其下所有子域名一起匹配:`NO_PROXY=example.com` 也会放行 `api.example.com`。前缀 `.` 或 `*.` 可以写,含义相同。条目可带 `:port`,`*` 则放行全部。带方括号与裸写的 IPv6 字面量都能匹配——裸写的 `::1` **不会**被读成主机 `:` 端口 `1`,而 undici 自带的匹配器正是这样出错的,这也是解析结果中同时携带 `::1` 与 `[::1]` 的原因。CIDR 不参与匹配:操作系统的绕过列表常含 `10.0.0.0/8`,必须改写成后缀形式。 ----- diff --git a/packages/util/http-proxy/src/install.ts b/packages/util/http-proxy/src/install.ts index d7035a12f7..1b1bb79326 100644 --- a/packages/util/http-proxy/src/install.ts +++ b/packages/util/http-proxy/src/install.ts @@ -24,8 +24,8 @@ let active: ProxyPolicy | undefined /** * 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 + * Owned by the OUTERMOST install: one layered over the launcher's 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 proxyEnvironmentForChild} keeps a value the user set rather than the one this process resolved from @@ -201,9 +201,9 @@ async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise`. */ + /** The environment variable that supplied the rejected value. */ readonly origin: string /** Operator-facing sentence naming the rejection and the way forward. Carries no credential. */ readonly message: string @@ -118,7 +118,7 @@ function readEnv( } /** - * What one environment or configuration slot supplied. A rejected slot is distinct from an absent + * What one environment variable 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. */ @@ -188,7 +188,7 @@ function resolveScheme(own: ProxyCandidate, ...fallbacks: (string | undefined)[] * 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. + * @param noProxy - the bypass list as the environment supplied it. * @returns the effective bypass list. */ function withLoopback(noProxy: string | undefined): string { @@ -254,8 +254,10 @@ export function isLoopbackHost(hostname: string): boolean { } /** - * 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 — + * Decide whether a bypass list exempts one URL. An entry names a host and matches it together with + * every subdomain under it — `example.com` also bypasses `api.example.com` — and a leading `.` or + * `*.` is accepted as the same thing; an entry may carry a `:port`, and `*` bypasses 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. diff --git a/packages/util/http-proxy/tests/install.spec.ts b/packages/util/http-proxy/tests/install.spec.ts index 4b02ea1ee8..52e8011e6f 100644 --- a/packages/util/http-proxy/tests/install.spec.ts +++ b/packages/util/http-proxy/tests/install.spec.ts @@ -215,8 +215,8 @@ describe('proxyRouteFor', () => { // very agent the branch described, so no second read can put the two on different routes. expect(route.dispatcher).toBe(getGlobalDispatcher()) const undici = await import('undici') - // Unmounting the plugin under an in-flight request is what a hot reload does. The shared - // dispatcher is closed, not destroyed, so the hop that already left finishes. + // Disposing the install while a request is in flight: the shared dispatcher is closed, not + // destroyed, so the hop that already left finishes. const inFlight = undici.fetch(proxyTarget, { dispatcher: route.dispatcher }) await dispose() await expect((await inFlight).text()).resolves.toBe('VIA-PROXY') @@ -307,7 +307,7 @@ describe('proxyEnvironmentForChild', () => { await withCleanProxyEnv(async () => { // The user exported one name, in one casing. process.env.HTTP_PROXY = proxyUrl - // The launcher installs first; mounting the plugin installs a second policy over it. + // The launcher installs first; a second `installProxyFromEnvironment` layers another policy over it. const outer = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: proxyUrl, NO_PROXY: 'example.com' })) try { const inner = await install(env({ HTTP_PROXY: nestedUrl, HTTPS_PROXY: nestedUrl })) From c44dcb7b853b56c8210083d2e2cf3d5e40b2465f Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 10:04:59 +0800 Subject: [PATCH 21/43] fix(app-boot): accept the proxy names from the Harness-home .env alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proxy guide told users a proxy could live in a project or `$DSH_HOME` `.env`. It could not: `loadLayeredEnv` refuses the four proxy names from any discovered file, as it refuses `PATH` and `NODE_OPTIONS`, and the launch fails with a pointer to `export`. That refusal is right for the invoking directory's file — it arrives with a clone, and a repository must not choose where the harness sends its traffic — and wrong for the user's own `$DSH_HOME/.env`, which already holds their API key. `readEnvLayer` now accepts `HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, and `NO_PROXY` from the directory that is the Harness home, and nowhere else. `DSH_HOME` is itself bootstrap-only, so no `.env` can relocate the exemption; the CA and TLS names in the same group stay refused everywhere, since they change what is trusted rather than where traffic goes. A project `.env` that sets a proxy name still fails the launch, and its message now names the home file as the second way out. Launching from inside the home directory reads that one file as the project layer; the exemption follows the directory. The seven existing refusal cases all write to the project layer and pass unchanged. Four new cases cover the home layer accepting both casings below an exported value, the home layer still refusing `SSL_CERT_FILE`, the project layer's new message, and the same-directory launch. The guide, both package READMEs, and the two Agent Notes that stated the old rule now state this one. --- ...4-configuration-source-ownership.i18n.yaml | 4 +- ...26-08-04-configuration-source-ownership.md | 4 +- ...08-04-configuration-source-ownership.zh.md | 4 +- ...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 +- docs/user/guide/network-proxy.i18n.yaml | 4 +- docs/user/guide/network-proxy.md | 2 +- docs/user/guide/network-proxy.zh.md | 2 +- packages/boot/app-boot/README.i18n.yaml | 4 +- packages/boot/app-boot/README.md | 2 +- packages/boot/app-boot/README.zh.md | 2 +- packages/boot/app-boot/src/index.ts | 32 ++++++-- packages/boot/app-boot/tests/app-boot.spec.ts | 74 +++++++++++++++++++ packages/util/http-proxy/README.i18n.yaml | 4 +- packages/util/http-proxy/README.md | 2 +- packages/util/http-proxy/README.zh.md | 2 +- 17 files changed, 127 insertions(+), 35 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml index 7f9dcea635..d8fbaea3d4 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.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-04-configuration-source-ownership.md -2026-08-04-configuration-source-ownership.md: 1fe5908ab77632732996bd1d5c1eed9c8ab048e6 -2026-08-04-configuration-source-ownership.zh.md: 197cb936cdff303e23425d008c7a2cb738500ae0 +2026-08-04-configuration-source-ownership.md: 29dd5fd623d38532502af8c4e4afd972236fc139 +2026-08-04-configuration-source-ownership.zh.md: e5cbf70826b9314daccb7a62e5da4603e38158de diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md index 1fe5908ab7..29dd5fd623 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md @@ -42,7 +42,7 @@ The launching environment wins because `DEEPSEEK_API_KEY=… dsh`, a CI secret, **The project the harness is launched in is trusted, by default and without a prompt.** A checkout may carry its own endpoint, its own ordinary variables, and its own key; the key ranks below the managed store, so a key stored through the Models page is never displaced by one a checkout happens to contain. `LaunchEnvironmentSnapshot.getFrom(name, sources)` still searches only the layers a caller names, and omitting one is a refusal rather than a demotion — the mechanism exists for decisions where a layer must be unreachable; this decision includes the project layer. -**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. +**Trust does not extend to changing the harness itself.** `loadLayeredEnv` rejects, at load and before anything is materialized, any `.env` that sets a variable governing how a process launches (`PATH`, `SHELL`, `NODE_OPTIONS`, `LD_PRELOAD`), which ambient program handles an operation (`EDITOR`, `PAGER`, `BROWSER`), what code a runtime executes before the program it was asked to run (`BASH_ENV`, `PERL5OPT`, `PYTHONSTARTUP`, `RUBYOPT`, `JAVA_TOOL_OPTIONS`, the Git hook commands), where model-visible instructions load from (the whole `DSH_*` namespace, `HOME`, `XDG_*`), or how the network is reached and trusted (proxy and CA variables). Matching is case-insensitive, so `https_proxy` is not a bypass. One exemption, recorded in [the proxy policy note](2026-08-27-outbound-proxy-policy.md): the four proxy names are accepted from `$DSH_HOME/.env`, which no `.env` can relocate, and still refused from the invoking directory's file. The line is that these take effect with no user action, before any turn, outside the permission policy and the sandbox. `DSH_PERMISSION_MODE` would switch off the approvals that make trusting a project meaningful at all, and `BASH_ENV` runs a file of the project's choosing on every single `bash -c` the bash tool issues — the project's code running under the agent's policy is the deal; the project rewriting that policy is not. Enumerating these is a losing game one variable at a time, which is why the whole `DSH_*` namespace is denied rather than an audited subset, and why the list is organised by what a variable *does* rather than by which runtime owns it. There is no opt-out: an escape hatch would have to be readable from somewhere, and anything a discovered file could set is the hole itself. @@ -53,7 +53,7 @@ The line is that these take effect with no user action, before any turn, outside ## Consequences - The web credential form now takes effect against an older key in the user's `.env`; only a key exported in the launching shell still makes it read-only, and the diagnostic says so. -- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. +- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break. - Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree. - Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation. - Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work. diff --git a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md index 197cb936cd..e5cbf70826 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md @@ -43,7 +43,7 @@ inherited process environment (read-only, wins) **harness 被启动于其中的项目默认可信,且不做询问。** 一个 checkout 可以携带自己的 endpoint、自己的普通变量和自己的密钥;密钥排在受管存储之下,因此通过 Models 页存下的密钥绝不会被 checkout 中恰好带有的那一个顶掉。`LaunchEnvironmentSnapshot.getFrom(name, sources)` 仍然只搜索调用方点名的层,省略某层仍是拒绝而不是降级——该机制供要求某一层不可达的决策使用;本决策包含项目层。 -**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。 +**信任不延伸到改变 harness 本身。** `loadLayeredEnv` 会在加载时、且在物化任何内容之前,拒绝任何设置了下列变量的 `.env`:决定进程如何启动的(`PATH`、`SHELL`、`NODE_OPTIONS`、`LD_PRELOAD`)、决定由哪个环境程序处理一项操作的(`EDITOR`、`PAGER`、`BROWSER`)、决定运行时在执行被要求运行的程序之前先执行哪些代码的(`BASH_ENV`、`PERL5OPT`、`PYTHONSTARTUP`、`RUBYOPT`、`JAVA_TOOL_OPTIONS`、Git 的钩子命令)、决定模型可见指令从哪里加载的(整个 `DSH_*` 命名空间、`HOME`、`XDG_*`),以及决定网络如何访问以及如何建立信任的(proxy 与 CA 变量)。匹配不区分大小写,因此 `https_proxy` 不是绕过手段。唯一的豁免记录在[代理策略笔记](2026-08-27-outbound-proxy-policy.zh.md)中:四个代理名可从 `$DSH_HOME/.env` 接受——没有任何 `.env` 能挪动该文件——但仍拒绝来自调用目录文件的同名变量。 这条界线在于:它们无需任何用户动作、在任何轮次开始之前、且在权限策略与沙箱之外就生效。`DSH_PERMISSION_MODE` 会关掉让「信任项目」根本成立的那道审批,而 `BASH_ENV` 会在 bash 工具每次发出 `bash -c` 时执行项目指定的文件——项目的代码在 agent(智能体)的策略下运行是约定,项目改写那份策略不是。一个变量一个变量地枚举是必输的游戏,所以整个 `DSH_*` 命名空间被拒绝而不是只拒绝一份经审查的子集,也所以这份清单是按变量*做什么*而不是按哪个运行时拥有它来组织的。不设逃生门:逃生门本身总得从某处读取,而任何被发现的文件能设置的东西,就是那个漏洞本身。 @@ -54,7 +54,7 @@ inherited process environment (read-only, wins) ## Consequences - Web 凭据表单现在能压过用户 `.env` 里更旧的密钥;只有在启动 shell 里 export 的密钥才会让它变成只读,诊断信息也会这么说。 -- 含 `DSH_*`、`PATH`、`BROWSER` 或 proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 +- 含 `DSH_*`、`PATH`、`BROWSER` 或(在调用目录中)proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。 - composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。 - 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。 - Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。 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 f8f7fa9937..8a97a29e19 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: 0de49f2c3302cfdc611d757828390eb8ef8efe84 -2026-08-27-outbound-proxy-policy.zh.md: 59175834f5257dd3065debffdda97d5fc9645c04 +2026-08-27-outbound-proxy-policy.md: ff81530764006419afdfd6ce9e75ce9eebe62f94 +2026-08-27-outbound-proxy-policy.zh.md: f289013ec7606c4d2c5cc4487b429000598b403b 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 0de49f2c33..ff81530764 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 @@ -10,13 +10,13 @@ Node's built-in `fetch` ignores `HTTP_PROXY` and `HTTPS_PROXY`. Every other tool 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. +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 `$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/util/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. +Resolution reads the launcher's snapshot rather than `process.env`, which is what makes a proxy in `$DSH_HOME/.env` work — the capability the environment-variable approach cannot have. Only that file: `loadLayeredEnv` refuses a proxy name in the project `.env` exactly as it refuses `PATH` or `NODE_OPTIONS` there, because that file arrives with a clone and must not choose the harness's route. The home file is exempt for the four proxy names alone, and `DSH_HOME` is itself bootstrap-only, so no `.env` can point the exemption at a directory a repository controls. **A library in `util/`, not a plugin.** Transport policy has one answer per process: nothing to swap, and no scope narrower than the process to give one. The package exports functions and mounts nothing — `boot`, `web`, `subprocess`, and `workflow` all consume it, and `util/` is the group every other group may depend on. @@ -58,7 +58,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the ## 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. +**Document `NODE_USE_ENV_PROXY=1` and stop.** Rejected on three measurements, above: invisible to `$DSH_HOME/.env`, 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. @@ -74,7 +74,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the ## 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. The launcher installs it exactly once, before the first plugin mounts. +A user who exports `HTTPS_PROXY`, or writes it into `$DSH_HOME/.env`, is proxied everywhere the harness makes a request, with no flag and no configuration. The launcher installs it exactly once, before the first plugin mounts. 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. 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 59175834f5..f289013ec7 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 @@ -10,13 +10,13 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 仓库曾短暂拥有过答案,又在无人察觉时弄丢了。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 无论标志如何都会覆盖全局的那个。 +即便照做,那句话也不可能生效,原因有三条且都经过实测。`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/util/http-proxy` 解析出 `ProxyPolicy`,并在 `runProfile` 中于环境快照提供之后、任何 entry 挂载之前完成安装。Node 的 `fetch` 解析的正是 undici 的全局 dispatcher,因此每一处普通 `fetch()` 以及每一个最终落到 `globalThis.fetch` 的 SDK 都无需改动即被覆盖——撰写时是九个调用点,未来新增的也自动覆盖。`loadLayeredEnv` 只有一个调用方,且 `apps/web` 不提供 bin,因此这一处即覆盖全部 profile,包括不叠加 `base` 的 `sdk-minimal`。 -解析读取的是启动器的快照而非 `process.env`,这正是让 `.env` 层中的代理生效的原因——也是环境变量方案不可能具备的能力。 +解析读取的是启动器的快照而非 `process.env`,这正是让 `$DSH_HOME/.env` 中的代理生效的原因——也是环境变量方案不可能具备的能力。仅限该文件:`loadLayeredEnv` 拒绝项目 `.env` 里的代理名,正如它在那里拒绝 `PATH` 或 `NODE_OPTIONS`,因为那个文件随 clone 一起到来,不得替 Harness 选择路由。home 文件仅对这四个代理名豁免,而 `DSH_HOME` 本身是 bootstrap-only,因此没有任何 `.env` 能把这份豁免指向仓库控制的目录。 **放在 `util/` 的库,而非插件。** 传输策略每个进程只有一个答案:没有可替换的实现,也没有比进程更窄的作用域可赋予。因此本包只导出函数、不挂载任何东西——`boot`、`web`、`subprocess` 与 `workflow` 都消费它,而 `util/` 正是其他所有组都可以依赖的那一组。 @@ -58,7 +58,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 ## Alternatives considered -**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 基于上文三条实测被否决:对 `.env` 层不可见、在最低支持的 Node 上不存在、且无论如何被 `web-fetch-http` 绕过。而这恰恰是仓库此前声称的做法。 +**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 基于上文三条实测被否决:对 `$DSH_HOME/.env` 不可见、在最低支持的 Node 上不存在、且无论如何被 `web-fetch-http` 绕过。而这恰恰是仓库此前声称的做法。 **把策略值传递到每一个调用点。** DeepSeek-Reasonix 在 98 处这样做,换来每提供方的 opt-out。被否决:该能力服务于本 Harness 并不具备的需求,而手工改九处意味着第十处会被遗忘——Pi 的变更日志正记录了 OAuth 与 Bedrock 两次事后补漏。它关于隔离性的论点确实成立,本方案改为显式处理 worker 线程、并以「dispose 后还原前一个 dispatcher」的断言来回应。 @@ -74,7 +74,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 ## Consequences -导出了 `HTTPS_PROXY`、或把它写进 `.env` 层的用户,在 Harness 发起请求的每一处都会走代理,无需任何标志与配置。启动器在第一个插件挂载之前恰好安装一次。 +导出了 `HTTPS_PROXY`、或把它写进 `$DSH_HOME/.env` 的用户,在 Harness 发起请求的每一处都会走代理,无需任何标志与配置。启动器在第一个插件挂载之前恰好安装一次。 由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `docs/user/guide/network-proxy.md` 说明了要导出哪些变量,以及为什么浏览器走代理而终端不走——这个「三套机制」的困惑是最常见的报障,且并非本 Harness 特有。 diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml index 9a942a0b8f..8bd0acddf0 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: 896e4b5416910cf34bcbe5297382f71cf3d3e2bb -network-proxy.zh.md: 8283a5836223b843f51ffd0cc972b803703e1a33 +network-proxy.md: da887195d3c76257f01b824f64cf41372b3717ed +network-proxy.zh.md: 97d637fc3efcbc23ceacb5ab95e9191bdadec5a7 diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md index 896e4b5416..da887195d3 100644 --- a/docs/user/guide/network-proxy.md +++ b/docs/user/guide/network-proxy.md @@ -11,7 +11,7 @@ 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. +Put both lines in your shell profile so every `dsh` invocation inherits them, or in `$DSH_HOME/.env` (`~/.dsh/.env` by default) next to your API key; an exported variable always wins over that file. A project's own `.env` cannot set them: it arrives with `git clone`, and DSH refuses to start rather than let a repository decide where your traffic goes. A proxy that needs credentials takes them in the URL: `http://user:password@proxy.example:8080`. DSH never prints the URL back: a diagnostic names the variable it rejected, so neither the username nor the password appears anywhere. diff --git a/docs/user/guide/network-proxy.zh.md b/docs/user/guide/network-proxy.zh.md index 8283a58362..97d637fc3e 100644 --- a/docs/user/guide/network-proxy.zh.md +++ b/docs/user/guide/network-proxy.zh.md @@ -11,7 +11,7 @@ export HTTPS_PROXY=http://127.0.0.1:7890 export HTTP_PROXY=http://127.0.0.1:7890 ``` -把这两行写进 shell 配置,这样每次调用 `dsh` 都会继承它们。DSH 还会读取启动目录与 `$DSH_HOME` 下的 `.env` 文件,因此只对某个项目生效的代理可以写在那里;真实环境变量始终优先于文件。 +把这两行写进 shell 配置,这样每次调用 `dsh` 都会继承它们;也可以写进 `$DSH_HOME/.env`(默认 `~/.dsh/.env`),和 API key 放在一起;导出的环境变量始终优先于该文件。项目自己的 `.env` 不能设置它们:它随 `git clone` 一起到来,DSH 宁可拒绝启动,也不让一个仓库决定你的流量去向。 需要凭据的代理把凭据写在 URL 里:`http://user:password@proxy.example:8080`。DSH 绝不会回显这个 URL:诊断只点名被拒绝的变量,因此用户名和密码都不会出现在任何地方。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index ef881a96ec..aa898932cf 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/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/boot/app-boot/README.md -README.md: a9ed535c2662237229e0702dcf41eae4f93af5df -README.zh.md: 4f7688f7db5060bb1bf00ca5d091ca4ad16466ea +README.md: ddbcc900fcbb38e83b35f517f9d38cf0e04927f3 +README.zh.md: 184e1a086d09bf6807fd8fb2d17f7157ba8deaa6 diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index a9ed535c26..ddbcc900fc 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -49,7 +49,7 @@ A profile is how one dsh installation ships different app surfaces: `web`, `head Your machine-local preferences also live in the Harness home: -- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, proxies, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line. +- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. The four proxy names (`HTTP_PROXY`, `HTTPS_PROXY`, `ALL_PROXY`, `NO_PROXY`) are accepted from the Harness-home file only, never from the invoking directory's, which arrives with a clone. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line. - **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead. Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback. diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 4f7688f7db..184e1a086d 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -49,7 +49,7 @@ profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`head 你的机器本地偏好同样位于 harness home 中: -- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。决定进程如何启动的变量(`PATH`、代理、`DSH_*`、`XDG_*` 等)会被文件拒绝:请改为导出。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。 +- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。决定进程如何启动的变量(`PATH`、`DSH_*`、`XDG_*` 等)会被文件拒绝:请改为导出。四个代理名(`HTTP_PROXY`、`HTTPS_PROXY`、`ALL_PROXY`、`NO_PROXY`)只从 harness home 的文件接受,绝不从调用目录的文件接受——后者随 clone 一起到来。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。 - **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个 config(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。 带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。 diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 85e25eb641..8059b4849c 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -119,9 +119,19 @@ const BOOTSTRAP_NAMES = new Set([ /** Name prefixes no discovered file may set. */ const BOOTSTRAP_PREFIXES = ['DSH_', 'XDG_', 'DYLD_', 'BASH_FUNC_'] +/** + * The bootstrap names the Harness-home `.env` alone may set. A proxy chooses the route every + * request takes, so the invoking directory's file — which arrives with a clone — keeps refusing + * them; the home file is the user's own, and `DSH_HOME` is itself bootstrap-only, so no `.env` can + * relocate this exemption. The CA and TLS names in the same group stay refused everywhere: they + * change what is trusted, not where traffic goes. + */ +const HOME_LAYER_PROXY_NAMES = new Set(['HTTP_PROXY', 'HTTPS_PROXY', 'ALL_PROXY', 'NO_PROXY']) + /** * Whether a variable may come only from the inherited process environment - * because it changes process, runtime, VCS, or network bootstrap. + * because it changes process, runtime, VCS, or network bootstrap. The Harness-home + * file is additionally allowed {@link HOME_LAYER_PROXY_NAMES}. * @param name - the variable name. * @returns true when only the inherited environment may supply it. */ @@ -136,13 +146,15 @@ function isBootstrapOnly(name: string): boolean { * @param binName - the diagnostic prefix on the thrown error. * @param dir - the directory whose `.env` to read. * @param warn - sink for the one-line unreadable-file diagnostic. + * @param home - the resolved Harness home; when `dir` is it, {@link HOME_LAYER_PROXY_NAMES} are accepted. * @returns the parsed entries, or `undefined` when the file is absent or unreadable. - * @throws when the file declares a name {@link isBootstrapOnly} rejects. + * @throws when the file declares a name {@link isBootstrapOnly} rejects and this layer may not set. */ function readEnvLayer( - binName: string, dir: string, warn: (line: string) => void, + binName: string, dir: string, warn: (line: string) => void, home: string, ): { path: string; values: Record } | undefined { const path = resolve(dir, '.env') + const isHome = resolve(dir) === home let content: string try { content = readFileSync(path, 'utf8') @@ -157,10 +169,16 @@ function readEnvLayer( const values = parseEnv(content) as Record for (const name of Object.keys(values)) { if (!isBootstrapOnly(name)) continue + const proxyName = HOME_LAYER_PROXY_NAMES.has(name.toUpperCase()) + if (isHome && proxyName) continue + // A proxy name has a second way out that the other bootstrap names do not, so its message says so. + const remedy = proxyName + ? `export ${name}, or put it in ${resolve(home, '.env')}, which does not travel with a repository` + : `export ${name} instead of putting it in a .env file` throw new Error( `${binName}: ${path} sets "${name}", which only the launching environment may set` + ' (it decides how this process starts, where its code and instructions load from, or how it' - + ` reaches the network); export ${name} instead of putting it in a .env file`, + + ` reaches the network); ${remedy}`, ) } return { path, values } @@ -175,7 +193,7 @@ function readEnvLayer( * @param cwd - the invoking directory whose `.env` is the project layer. * @param warn - sink for the one-line misconfiguration diagnostics. * @returns this run's frozen environment snapshot. - * @throws when either file declares a bootstrap-only variable. + * @throws when either file declares a bootstrap-only variable, except {@link HOME_LAYER_PROXY_NAMES} in the Harness-home file. */ export function loadLayeredEnv( binName: string, cwd: string = process.cwd(), @@ -184,8 +202,8 @@ export function loadLayeredEnv( const home = resolveDshHome() const inherited = { ...process.env } as Record // Parse both layers first: a rejection must not leave one file applied. - const project = readEnvLayer(binName, cwd, warn) - const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn) + const project = readEnvLayer(binName, cwd, warn, home) + const user = home === resolve(cwd) ? undefined : readEnvLayer(binName, home, warn, home) // Apply the checked values without replacing a higher-ranked name. for (const layer of [project, user]) { if (layer === undefined) continue diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index e5fc24aaf3..d39d38e82c 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -149,6 +149,80 @@ describe('loadLayeredEnv', () => { } }) + const PROXY = ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy', 'NO_PROXY', 'no_proxy'] as const + function clearProxy(): void { + for (const name of PROXY) Reflect.deleteProperty(process.env, name) + } + + it('accepts the proxy names from the Harness-home .env, below an exported one', () => { + const home = tmp() + const project = tmp() + // Both casings, because a shell profile writes either and the rejection matches both. + writeFileSync(join(home, '.env'), 'HTTP_PROXY=http://from-home:8080\nhttps_proxy=http://from-home-lower:8080\nNO_PROXY=example.com\n') + clear(); clearProxy() + vi.stubEnv('DSH_HOME', home) + vi.stubEnv('HTTPS_PROXY', 'http://exported:8080') + try { + const snapshot = loadLayeredEnv(NAME, project, vi.fn()) + expect(snapshot.get('HTTP_PROXY')).toEqual({ value: 'http://from-home:8080', source: 'user-env', path: join(home, '.env') }) + expect(snapshot.get('https_proxy')).toEqual({ value: 'http://from-home-lower:8080', source: 'user-env', path: join(home, '.env') }) + expect(snapshot.get('NO_PROXY')?.value).toBe('example.com') + // The launching shell still outranks the file. + expect(snapshot.get('HTTPS_PROXY')).toEqual({ value: 'http://exported:8080', source: 'process' }) + expect(process.env.HTTP_PROXY).toBe('http://from-home:8080') + } finally { + clear(); clearProxy() + vi.unstubAllEnvs() + } + }) + + it('still refuses every other bootstrap name in the Harness-home .env', () => { + const home = tmp() + const project = tmp() + // A CA path sits in the same network group as the proxy names and changes what is trusted, + // not where traffic goes; the exemption must not widen to it. + writeFileSync(join(home, '.env'), 'SSL_CERT_FILE=/tmp/ca.pem\n') + clear() + vi.stubEnv('DSH_HOME', home) + try { + expect(() => loadLayeredEnv(NAME, project, vi.fn())).toThrow(/only the launching environment may set/) + } finally { + clear() + vi.unstubAllEnvs() + } + }) + + it('names the Harness-home file as the way out when a project .env sets a proxy', () => { + const home = tmp() + const project = tmp() + writeFileSync(join(project, '.env'), 'HTTP_PROXY=http://attacker.example\n') + clear(); clearProxy() + vi.stubEnv('DSH_HOME', home) + try { + expect(() => loadLayeredEnv(NAME, project, vi.fn())) + .toThrow(`export HTTP_PROXY, or put it in ${join(home, '.env')}, which does not travel with a repository`) + expect(process.env.HTTP_PROXY).toBeUndefined() + } finally { + clear(); clearProxy() + vi.unstubAllEnvs() + } + }) + + it('treats the invoking directory as the Harness home when they are the same directory', () => { + const home = tmp() + writeFileSync(join(home, '.env'), 'HTTP_PROXY=http://from-home:8080\n') + clear(); clearProxy() + vi.stubEnv('DSH_HOME', home) + try { + // Launched from inside the home itself, its one file is read as the project layer; the + // exemption follows the directory, not the layer name. + expect(loadLayeredEnv(NAME, home, vi.fn()).get('HTTP_PROXY')?.value).toBe('http://from-home:8080') + } finally { + clear(); clearProxy() + vi.unstubAllEnvs() + } + }) + it('reports each file value with its absolute path', () => { const home = tmp() const project = tmp() diff --git a/packages/util/http-proxy/README.i18n.yaml b/packages/util/http-proxy/README.i18n.yaml index b5a74ef1a5..833f165024 100644 --- a/packages/util/http-proxy/README.i18n.yaml +++ b/packages/util/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/util/http-proxy/README.md -README.md: f0dab162eba192ee786e2eed9f19e5739f2d9ad1 -README.zh.md: acdd8200a3f304d9aa63127ba97652d1eadef0f4 +README.md: 1a2098a7ba6e9a43df54e06079dd2c37899ed3a6 +README.zh.md: 6589fce3c7a618eb17f3507437ab45550a760a8a diff --git a/packages/util/http-proxy/README.md b/packages/util/http-proxy/README.md index f0dab162eb..1a2098a7ba 100644 --- a/packages/util/http-proxy/README.md +++ b/packages/util/http-proxy/README.md @@ -49,7 +49,7 @@ That gate cannot see inside an SDK, so every outbound call site in the repositor ### 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. +`http_proxy`, `https_proxy`, `no_proxy`, and `all_proxy`, lowercase first and uppercase as the fallback, with a blank value treated as unset. `ALL_PROXY` backs both schemes, and HTTPS falls back to the HTTP proxy last — neither Node nor undici derives the first of these on its own. Values come from the launcher's snapshot: an exported variable first, then `$DSH_HOME/.env`. A project's own `.env` cannot carry these names — that file arrives with a clone, and the launcher refuses to start rather than let a repository choose where the harness sends its traffic. Loopback is always bypassed — `localhost`, the whole `127.0.0.0/8` range, `::1`, `0.0.0.0`, and the IPv4-mapped spellings of those. The harness's own Web UI, Connection transport, and every local test server would otherwise route through the proxy and loop. The published bypass list names only the four literal entries an environment reader can match; `proxyForUrl` recognises the range itself, because a list entry cannot express one. diff --git a/packages/util/http-proxy/README.zh.md b/packages/util/http-proxy/README.zh.md index acdd8200a3..6589fce3c7 100644 --- a/packages/util/http-proxy/README.zh.md +++ b/packages/util/http-proxy/README.zh.md @@ -49,7 +49,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`,因此在代 ### 策略读取哪些值 -`http_proxy`、`https_proxy`、`no_proxy` 与 `all_proxy`,小写优先、大写兜底,空值视为未设置。`ALL_PROXY` 为两种协议兜底,HTTPS 最后回退到 HTTP 代理——其中第一条 Node 与 undici 都不会自行推导。取值来自启动器的快照,因此写在项目或 `$DSH_HOME` 的 `.env` 层中的代理同样生效;真实环境变量仍然高于两者。 +`http_proxy`、`https_proxy`、`no_proxy` 与 `all_proxy`,小写优先、大写兜底,空值视为未设置。`ALL_PROXY` 为两种协议兜底,HTTPS 最后回退到 HTTP 代理——其中第一条 Node 与 undici 都不会自行推导。取值来自启动器的快照:先看导出的环境变量,再看 `$DSH_HOME/.env`。项目自己的 `.env` 不能携带这些名字——那个文件随 clone 一起到来,启动器宁可拒绝启动,也不让一个仓库决定 Harness 把流量发往何处。 loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、`0.0.0.0`,以及它们的 IPv4 映射写法。否则 Harness 自己的 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。发布出去的绕过列表只包含读取环境的消费者能匹配的四个字面量条目;`proxyForUrl` 自行识别整个网段,因为列表条目无法表达一个范围。 From 6ecdc9390142a69f04a8903c721decca7000a5d4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 10:19:23 +0800 Subject: [PATCH 22/43] fix(http-proxy): give children the user's environment under a layered direct policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A direct policy installed over a proxied one swapped the global dispatcher but left `process.env` holding the outer install's published normalization. `proxyEnvironmentForChild()` returns nothing under a direct policy, and `scrubbedParentEnv()` copies `process.env` as it is, so a child spawned in that window inherited values no active policy stood behind: an `HTTPS_PROXY` derived from `HTTP_PROXY` the user never set, or the loss of a SOCKS value they set for `curl` and this package had refused. The direct branch now writes the user's own values — the record the outermost install keeps — back into `process.env` for the window, and re-applies the outer install's published values when it ends. An install underneath that proxied nothing published nothing, so there is nothing to put back. Publishing and restoring share one `writeProxyEnv`; the empty-string special case it replaced was unreachable, since a resolved bypass list always carries the loopback entries and an accepted proxy URL is never empty. The nesting is reachable only from tests since the plugin was removed; the fix keeps the disposer symmetric for whoever layers installs next. --- packages/util/http-proxy/src/install.ts | 60 ++++++++++++++----- .../util/http-proxy/tests/install.spec.ts | 45 ++++++++++++++ 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/packages/util/http-proxy/src/install.ts b/packages/util/http-proxy/src/install.ts index 1b1bb79326..0fa3c85c24 100644 --- a/packages/util/http-proxy/src/install.ts +++ b/packages/util/http-proxy/src/install.ts @@ -76,28 +76,54 @@ export function proxyRouteFor(url: URL): ProxyRoute { * @returns a function restoring every name this call changed. */ function applyPolicyEnv(policy: ProxyPolicy): () => void { + const previousInherited = inheritedProxyEnv + inheritedProxyEnv = previousInherited ?? snapshotProxyEnv() + const published: Record = {} + 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) published[name] = value + } + const restore = writeProxyEnv(published) + return () => { + restore() + inheritedProxyEnv = previousInherited + } +} + +/** + * Read every proxy name this package publishes, as `process.env` holds it now. + * + * @returns one entry per name in {@link POLICY_ENV_NAMES}; `undefined` marks an absent name. + */ +function snapshotProxyEnv(): Record { + const snapshot: Record = {} + for (const names of Object.values(POLICY_ENV_NAMES)) { + for (const name of names) snapshot[name] = process.env[name] + } + return snapshot +} + +/** + * Set every proxy name to the value `values` holds for it, removing a name whose value is `undefined`. + * + * @param values - the value each name in {@link POLICY_ENV_NAMES} should hold. + * @returns a function restoring every name to what it held before this call. + */ +function writeProxyEnv(values: Readonly>): () => 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]) - } - 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) { - if (value === undefined || value === '') Reflect.deleteProperty(process.env, name) - else process.env[name] = value - } + const previous = snapshotProxyEnv() + for (const name of Object.keys(previous)) { + const value = values[name] + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value } return () => { - for (const [name, value] of previous) { + for (const [name, value] of Object.entries(previous)) { if (value === undefined) Reflect.deleteProperty(process.env, name) else process.env[name] = value } - inheritedProxyEnv = previousInherited } } @@ -159,6 +185,11 @@ async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise Promise { }) }) +describe('the environment while a direct policy is layered over a proxied one', () => { + it('hands a child the user\'s own values, and the outer normalization again afterwards', async () => { + await withCleanProxyEnv(async () => { + // The user exported one usable proxy and one this package refuses. + process.env.HTTP_PROXY = proxyUrl + process.env.https_proxy = 'socks5://127.0.0.1:1080' + const outer = await install(env({ HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' })) + try { + // The outer install published its policy: the refused scheme is removed in both casings. + expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined]) + const off = await install(env({})) + try { + // A spawned child copies `process.env`, and `proxyEnvironmentForChild()` adds nothing under a + // direct policy — so what it copies has to be the user's own environment, not a normalization + // no active policy stands behind: the SOCKS value they set for `curl` is theirs again. + expect(process.env.HTTP_PROXY).toBe(proxyUrl) + expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toContain('socks5://127.0.0.1:1080') + expect(proxyEnvironmentForChild()).toEqual({}) + } finally { + await off.dispose() + } + // Ending the window re-applies what the outer install published. + expect([process.env.HTTPS_PROXY, process.env.https_proxy]).toEqual([undefined, undefined]) + expect(process.env.HTTP_PROXY).toBe(proxyUrl) + } finally { + await outer.dispose() + } + }) + }) + + it('touches no environment when the install underneath proxied nothing', async () => { + process.env.HTTP_PROXY = 'http://untouched.example' + const outer = await install(env({})) + const inner = await install(env({})) + try { + expect(process.env.HTTP_PROXY).toBe('http://untouched.example') + } finally { + await inner.dispose() + await outer.dispose() + expect(process.env.HTTP_PROXY).toBe('http://untouched.example') + delete process.env.HTTP_PROXY + } + }) +}) + describe('the published environment', () => { it('restores every name from one snapshot taken before any write', async () => { process.env.http_proxy = 'http://before.example' From 93bba8ef6797f696a847f391dcd9c77906a7cab2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 10:27:36 +0800 Subject: [PATCH 23/43] fix(http-proxy): withhold NODE_USE_ENV_PROXY when the child receives a refused proxy value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `proxyEnvironmentForChild()` hands a child the proxy values the user exported, including one this package refused — a SOCKS URL kept because `curl` reads it — and sets `NODE_USE_ENV_PROXY=1` so a child Node honors them. Node parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program and exits on any scheme other than `http:` or `https:`. So a user with a usable `HTTP_PROXY` and `HTTPS_PROXY=socks4://…` lost every Node child — stdio MCP servers, subagent CLIs, `npm` in the bash tool — before its first line, while this process had reported only that the scheme stayed direct. Measured on Node 24.17: `socks4://`, `ftp://`, and a malformed value all exit 1; `socks5://` is accepted there and only there. The flag is now withheld whenever a value under the names Node parses is one `isSupportedProxyUrl` refuses. Such a child connects directly, which is what this process already said about that scheme, and `curl` still reads the value it was kept for. Node does not read `ALL_PROXY`, so a refused value there alone changes nothing. The socks5 case in `install.spec.ts` now asserts the flag absent; the ALL_PROXY fill case asserts it present; a new case spawns a real child Node under the overlay for each refused shape and asserts it starts. --- ...2026-08-27-outbound-proxy-policy.i18n.yaml | 4 +-- .../2026-08-27-outbound-proxy-policy.md | 2 ++ .../2026-08-27-outbound-proxy-policy.zh.md | 2 ++ docs/user/guide/network-proxy.i18n.yaml | 4 +-- docs/user/guide/network-proxy.md | 2 +- docs/user/guide/network-proxy.zh.md | 2 +- packages/util/http-proxy/README.i18n.yaml | 4 +-- packages/util/http-proxy/README.md | 4 +-- packages/util/http-proxy/README.zh.md | 4 +-- packages/util/http-proxy/src/install.ts | 11 +++++++ packages/util/http-proxy/src/policy.ts | 12 +++++++ .../util/http-proxy/tests/install.spec.ts | 33 ++++++++++++++++++- 12 files changed, 71 insertions(+), 13 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 8a97a29e19..7676532850 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: ff81530764006419afdfd6ce9e75ce9eebe62f94 -2026-08-27-outbound-proxy-policy.zh.md: f289013ec7606c4d2c5cc4487b429000598b403b +2026-08-27-outbound-proxy-policy.md: 67927a9d1404e0b14c6e420cc5bea462be5c87ad +2026-08-27-outbound-proxy-policy.zh.md: cf0b203a061522450a2a1b0c337a060b0c387684 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 ff81530764..67927a9d14 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 @@ -44,6 +44,8 @@ The URL-level policy is untouched: `http(s)` only, no embedded credentials, the **A spawned child gets the policy through its environment; a model-executing worker gets nothing.** `proxyEnvironmentForChild()` 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. +The child keeps the user's own values, and that is what once broke it. Node parses `HTTP_PROXY` and `HTTPS_PROXY` under `NODE_USE_ENV_PROXY` before running the program and exits on any scheme other than `http:` or `https:`; a `socks4://` kept for `curl` therefore ended every Node child — MCP servers, subagent CLIs, `npm` — before its first line, while this process had reported only that the scheme stayed direct. Measured on Node 24.17: `socks4://`, `ftp://`, and a malformed value all exit 1; `socks5://` happens to be accepted there. The flag is now withheld whenever a value the child receives is one this package refused, so such a child connects directly and `curl` still reads the value it was kept for. Handing the child the resolved value instead would have kept Node proxied at the price of silently rewriting what the user set for another tool. + 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. E2B is handed `route.proxy` from `proxyRouteFor`, the same call `web-fetch-http` makes. Telemetry is deliberately left direct, and that exclusion is the more interesting half. 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 f289013ec7..cf0b203a06 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 @@ -44,6 +44,8 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 **派生的子进程通过环境获得策略;执行模型代码的 worker 什么也不获得。** `proxyEnvironmentForChild()` 并入 `scrubbedParentEnv()`——每个 spawner 本就共享的那一个函数。workflow worker **不**接收它:它执行的是模型编写的脚本体,而代理 URL 可能携带 `user:password`。这与 code runtime 保持的隔离相同,也是 `docs/defensive-patterns.md` 的要求,因此 workflow 自身的请求直连。 +子进程拿到的是用户自己的值,而这恰恰曾把它弄坏。Node 在 `NODE_USE_ENV_PROXY` 下会在运行程序之前先解析 `HTTP_PROXY` 与 `HTTPS_PROXY`,遇到 `http:`/`https:` 之外的协议直接退出;于是一个为 `curl` 保留的 `socks4://` 会让每个 Node 子进程——MCP server、subagent CLI、`npm`——在第一行之前就终结,而本进程此前只报告过该协议保持直连。在 Node 24.17 上实测:`socks4://`、`ftp://` 与畸形值均以 1 退出;`socks5://` 恰好在该版本被接受。现在只要子进程收到的某个值是本包拒绝过的,就扣下该标志,这样的子进程直连,`curl` 仍读到为它保留的值。若改为把解析后的值交给子进程,Node 固然能继续走代理,代价却是悄悄改写用户为另一工具设置的值。 + 这接受了一处已记录的接缝。此类上下文按 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。两者都实测为直连。E2B 接收 `proxyRouteFor` 给出的 `route.proxy`,与 `web-fetch-http` 调的是同一个函数。遥测则被有意保留为直连,而这个排除项才是更值得说的一半。 diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml index 8bd0acddf0..92e08b46e0 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: da887195d3c76257f01b824f64cf41372b3717ed -network-proxy.zh.md: 97d637fc3efcbc23ceacb5ab95e9191bdadec5a7 +network-proxy.md: d53d48688490c741f0ed7f950c5ff39db02dc1e9 +network-proxy.zh.md: 1eee1e67abb700e15f3b2cea1b22bf036c694302 diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md index da887195d3..d53d486884 100644 --- a/docs/user/guide/network-proxy.md +++ b/docs/user/guide/network-proxy.md @@ -57,7 +57,7 @@ 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. +**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. If one of your proxy variables holds a value DSH rejected — a SOCKS URL, say — Node-based tools also connect directly rather than fail to start, while `curl` and `git` still read that value. **A password in the proxy URL reaches those tools too.** `HTTPS_PROXY=http://alice:s3cret@proxy.example:8080` is a normal environment variable, so every command DSH runs — including the ones the model writes — can read it, and a command that prints its environment puts the password in output that is kept. This is how the variable already behaves for everything else in your shell. If that matters, give the proxy a credential-free entry point, or authenticate it some other way than in the URL. diff --git a/docs/user/guide/network-proxy.zh.md b/docs/user/guide/network-proxy.zh.md index 97d637fc3e..1eee1e67ab 100644 --- a/docs/user/guide/network-proxy.zh.md +++ b/docs/user/guide/network-proxy.zh.md @@ -57,7 +57,7 @@ export NODE_EXTRA_CA_CERTS=/path/to/corporate-ca.pem Node 只在进程启动时读取该变量,所以要在运行 `dsh` 之前导出。 -**DSH 替你运行的工具遵循同一个代理。** bash 工具里的命令、`git`、`gh`,以及作为子进程启动的 MCP 服务器都会继承这些变量。子进程若本身是 Node 程序,则需 Node 22.21 或更高版本才会遵循;更旧的 Node 会直连。 +**DSH 替你运行的工具遵循同一个代理。** bash 工具里的命令、`git`、`gh`,以及作为子进程启动的 MCP 服务器都会继承这些变量。子进程若本身是 Node 程序,则需 Node 22.21 或更高版本才会遵循;更旧的 Node 会直连。如果你的某个代理变量是 DSH 拒绝的值——比如 SOCKS URL——基于 Node 的工具同样直连而不是起不来,`curl` 与 `git` 则仍会读取那个值。 **代理 URL 里的密码同样会到达这些工具。** `HTTPS_PROXY=http://alice:s3cret@proxy.example:8080` 就是一个普通环境变量,因此 DSH 运行的每一条命令——包括模型编写的那些——都能读到它,而打印环境的命令会把密码写进被保留的输出。这与该变量在你 shell 里对其他一切程序的行为一致。若这一点重要,请为代理提供一个无需凭据的入口,或改用 URL 之外的方式认证。 diff --git a/packages/util/http-proxy/README.i18n.yaml b/packages/util/http-proxy/README.i18n.yaml index 833f165024..18e7ec1621 100644 --- a/packages/util/http-proxy/README.i18n.yaml +++ b/packages/util/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/util/http-proxy/README.md -README.md: 1a2098a7ba6e9a43df54e06079dd2c37899ed3a6 -README.zh.md: 6589fce3c7a618eb17f3507437ab45550a760a8a +README.md: 023d8a2bad23647072fd249b862f4fe3ca865139 +README.zh.md: 6fcdf6b97dc6eb4f413b704d522b665c83ea70ca diff --git a/packages/util/http-proxy/README.md b/packages/util/http-proxy/README.md index 1a2098a7ba..023d8a2bad 100644 --- a/packages/util/http-proxy/README.md +++ b/packages/util/http-proxy/README.md @@ -66,7 +66,7 @@ A proxy value the package cannot use — a SOCKS or PAC URL, an unparseable stri **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 the user's own values, and the resolved policy for what they left unset.** A scheme the user named in either casing reaches a child exactly as they wrote it, so a SOCKS proxy `curl` uses is never replaced by an HTTP one named for another scheme. A scheme they named in neither casing carries the resolved value instead, because otherwise the child's routing diverges from its parent's: Node's `NODE_USE_ENV_PROXY` does not read `ALL_PROXY`. The bypass list is always the resolved one — it only ever adds the loopback entries, so nothing the user wrote is lost. The cost of one routing answer for parent and child alike is that `curl` also sees the `https:` proxy this package derives from the HTTP one. +**A child inherits the user's own values, and the resolved policy for what they left unset.** A scheme the user named in either casing reaches a child exactly as they wrote it, so a SOCKS proxy `curl` uses is never replaced by an HTTP one named for another scheme. A scheme they named in neither casing carries the resolved value instead, because otherwise the child's routing diverges from its parent's: Node's `NODE_USE_ENV_PROXY` does not read `ALL_PROXY`. The bypass list is always the resolved one — it only ever adds the loopback entries, so nothing the user wrote is lost. The cost of one routing answer for parent and child alike is that `curl` also sees the `https:` proxy this package derives from the HTTP one. One exception protects the child itself: when a value it receives is one this package refused — a SOCKS URL kept for `curl` — the `NODE_USE_ENV_PROXY` flag is withheld, because Node parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program and exits on such a value. A child Node then connects directly, as this process already reported for that scheme, instead of failing to start. ### Source map @@ -108,7 +108,7 @@ These limits define when the package is a poor fit. They are current package con - **No SOCKS, PAC, or operating-system proxy detection** — only `http(s)://` proxy URLs from the environment. A macOS or Windows system-proxy setting is not read, so a user who only toggled it in a proxy application must still export the variables; a SOCKS URL is reported and that scheme stays direct rather than borrowing another scheme's proxy. - **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 spawned child honors the policy only on a new enough runtime** — it reads the published environment through Node's `NODE_USE_ENV_PROXY` (22.21+, 24+), and the engines range admits 22.19 and 22.20, where such a child stays direct. A child also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in their separators and IPv4-range support. Nothing in this process depends on a Node version: every in-process request reaches the global dispatcher. +- **A spawned child honors the policy only on a new enough runtime, and only when every value it inherits is one Node accepts** — it reads the published environment through Node's `NODE_USE_ENV_PROXY` (22.21+, 24+), and the engines range admits 22.19 and 22.20, where such a child stays direct. A user whose environment also names a SOCKS or otherwise refused proxy leaves every child Node direct: the flag is withheld so the child can start at all. A child also matches bypass entries with Node's own `NO_PROXY` rules, which differ from this package's in their separators and IPv4-range support. Nothing in this process depends on a Node version: every in-process request reaches the global dispatcher. - **Telemetry is direct by design** — the OTLP exporter posts through `node:http`, which no global dispatcher reaches. Routing it would need either an `http.Agent` whose `proxyEnv` option post-dates the lowest supported Node, or the SDK's `fetch` transport, which has no compression while the shipped profile enables gzip. Telemetry is the one channel whose loss costs the user nothing, so it stays where it was; `DSH_TELEMETRY_MODE=DISABLED` turns it off. - **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`. diff --git a/packages/util/http-proxy/README.zh.md b/packages/util/http-proxy/README.zh.md index 6589fce3c7..6fcdf6b97d 100644 --- a/packages/util/http-proxy/README.zh.md +++ b/packages/util/http-proxy/README.zh.md @@ -66,7 +66,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` **一次解析,一个匹配器。** `proxyForUrl()` 与已安装的 dispatcher 绝不能对同一个 URL 给出不同答案,否则 `dsh-web-fetch-http` 会把 dispatcher 本打算隧道转发的连接固定到某个地址上。因此该 dispatcher 是一个 `Agent`,其按 origin 调用的 `factory` 自身调用 `proxyForUrl()`,不存在可能与第一个解析器产生漂移的第二个解析器。undici 的 `EnvHttpProxyAgent` 在此无法胜任:没有 `HTTPS_PROXY` 时它让 `https:` 复用 HTTP 代理,于是本包在拒绝用户为该 scheme 指定的 URL 后本应保持直连的 scheme 仍会被隧道转发。 -**子进程继承用户自己的值,以及用户未设置部分的解析结果。** 用户以任一大小写指定过的 scheme,会以他们书写的形式原样传给子进程,因此用户为 `curl` 设置的 SOCKS 代理绝不会被替换成为其他 scheme 指定的 HTTP 代理。两种大小写都未指定的 scheme 则携带解析值,否则子进程的路由会与父进程分歧:Node 的 `NODE_USE_ENV_PROXY` 不读 `ALL_PROXY`。绕过列表始终采用解析结果——它只会追加 loopback 条目,用户写下的内容不会丢失。让父子进程只有一个路由答案的代价是:`curl` 也会看到本包由 HTTP 代理推导出的 `https:` 代理。 +**子进程继承用户自己的值,以及用户未设置部分的解析结果。** 用户以任一大小写指定过的 scheme,会以他们书写的形式原样传给子进程,因此用户为 `curl` 设置的 SOCKS 代理绝不会被替换成为其他 scheme 指定的 HTTP 代理。两种大小写都未指定的 scheme 则携带解析值,否则子进程的路由会与父进程分歧:Node 的 `NODE_USE_ENV_PROXY` 不读 `ALL_PROXY`。绕过列表始终采用解析结果——它只会追加 loopback 条目,用户写下的内容不会丢失。让父子进程只有一个路由答案的代价是:`curl` 也会看到本包由 HTTP 代理推导出的 `https:` 代理。有一处例外是为了保护子进程自身:当子进程收到的某个值是本包拒绝过的——比如为 `curl` 保留的 SOCKS URL——就不再设置 `NODE_USE_ENV_PROXY`,因为 Node 在该标志下会在运行程序之前先解析 `HTTP_PROXY` 与 `HTTPS_PROXY`,遇到这类值直接退出。此时子 Node 直连(本进程已为该协议如此报告),而不是根本起不来。 ### 源码地图 @@ -108,7 +108,7 @@ loopback 始终被绕过——`localhost`、整个 `127.0.0.0/8` 段、`::1`、` - **不支持 SOCKS、PAC 或操作系统代理探测**——只接受来自环境的 `http(s)://` 代理 URL。不会读取 macOS 或 Windows 的系统代理设置,因此仅在代理软件里拨了开关的用户仍须导出环境变量;SOCKS URL 会被报告,且该协议保持直连,不会借用另一协议的代理。 - **不支持自定义证书颁发机构**——做 TLS 拦截的企业代理需要在启动前为进程设置 `NODE_EXTRA_CA_CERTS`,本包既不设置也不校验它。 -- **派生的子进程只在足够新的运行时上遵循策略**——它通过 Node 的 `NODE_USE_ENV_PROXY` 读取已发布的环境(22.21+、24+),而 engines 范围允许 22.19 与 22.20,在这两个版本上这样的子进程保持直连。子进程还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。本进程内不依赖任何 Node 版本:每一次进程内请求都会落到全局 dispatcher。 +- **派生的子进程只在足够新的运行时上遵循策略,且仅当它继承的每个值都是 Node 接受的**——它通过 Node 的 `NODE_USE_ENV_PROXY` 读取已发布的环境(22.21+、24+),而 engines 范围允许 22.19 与 22.20,在这两个版本上这样的子进程保持直连。若用户环境里还有 SOCKS 或其他被拒的代理,所有子 Node 都保持直连:标志被扣下,子进程才起得来。子进程还会按 Node 自己的 `NO_PROXY` 规则匹配绕过条目,其分隔符与 IPv4 区间处理与本包不同。本进程内不依赖任何 Node 版本:每一次进程内请求都会落到全局 dispatcher。 - **遥测按设计直连**——OTLP 导出器通过 `node:http` 投递,全局 dispatcher 触及不到。要让它走代理,要么依赖 `http.Agent` 的 `proxyEnv`,而该选项晚于本项目支持的最低 Node 版本;要么改用 SDK 的 `fetch` 传输,但它没有压缩能力,而随附配置启用了 gzip。遥测是唯一一条丢失了对用户毫无代价的通道,因此维持原状;`DSH_TELEMETRY_MODE=DISABLED` 可关闭它。 - **执行模型编写代码的 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/util/http-proxy/src/install.ts b/packages/util/http-proxy/src/install.ts index 0fa3c85c24..1bd05bbcd3 100644 --- a/packages/util/http-proxy/src/install.ts +++ b/packages/util/http-proxy/src/install.ts @@ -9,6 +9,7 @@ import type { Dispatcher, Pool } from 'undici' import { + isSupportedProxyUrl, POLICY_ENV_NAMES, PROXY_ENV_NAMES, proxyForUrl, @@ -246,6 +247,12 @@ async function installGlobalProxy(policy: ProxyPolicy): Promise<() => Promise inherited[name] !== undefined) for (const name of names) overlay[name] = named ? inherited[name] : resolved } + const parsedByNode = [...POLICY_ENV_NAMES.httpProxy, ...POLICY_ENV_NAMES.httpsProxy] + if (parsedByNode.some(name => overlay[name] !== undefined && !isSupportedProxyUrl(overlay[name]))) { + delete overlay.NODE_USE_ENV_PROXY + } return overlay } diff --git a/packages/util/http-proxy/src/policy.ts b/packages/util/http-proxy/src/policy.ts index d02a59b805..cf89f09709 100644 --- a/packages/util/http-proxy/src/policy.ts +++ b/packages/util/http-proxy/src/policy.ts @@ -170,6 +170,18 @@ function acceptProxyUrl( return { kind: 'accepted', value: candidate.value } } +/** + * Whether a proxy URL is one this package accepts: parseable, with an `http:` or `https:` scheme. + * The same test {@link acceptProxyUrl} applies, without its diagnostics. + * + * @param value - the proxy URL as an environment variable holds it. + * @returns true when the URL would be accepted. + */ +export function isSupportedProxyUrl(value: string): boolean { + const parsed = URL.parse(value) + return parsed !== null && SUPPORTED_PROTOCOLS.has(parsed.protocol) +} + /** * 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. diff --git a/packages/util/http-proxy/tests/install.spec.ts b/packages/util/http-proxy/tests/install.spec.ts index e514997c7b..a717bec599 100644 --- a/packages/util/http-proxy/tests/install.spec.ts +++ b/packages/util/http-proxy/tests/install.spec.ts @@ -1,3 +1,4 @@ +import { spawnSync } from 'node:child_process' import { createServer, type Server } from 'node:http' import type { AddressInfo } from 'node:net' import { afterEach, beforeAll, afterAll, describe, expect, it } from 'vitest' @@ -278,7 +279,9 @@ describe('proxyEnvironmentForChild', () => { // cannot route it. expect(child.no_proxy).toBe('example.com,localhost,127.0.0.1,::1,[::1]') expect(child.NO_PROXY).toBe('example.com,localhost,127.0.0.1,::1,[::1]') - expect(child.NODE_USE_ENV_PROXY).toBe('1') + // The SOCKS value kept for `curl` is one Node would refuse at startup, so the flag that makes + // Node read it is withheld and a child Node connects directly rather than failing to start. + expect(child.NODE_USE_ENV_PROXY).toBeUndefined() } finally { await dispose() } @@ -297,12 +300,40 @@ describe('proxyEnvironmentForChild', () => { expect(child.http_proxy).toBe(proxyUrl) expect(child.HTTPS_PROXY).toBe(proxyUrl) expect(child.https_proxy).toBe(proxyUrl) + expect(child.NODE_USE_ENV_PROXY).toBe('1') } finally { await dispose() } }) }) + it.each(['socks4://127.0.0.1:1080', 'ftp://p:1', 'not a url'])( + 'withholds NODE_USE_ENV_PROXY when the child receives %s, so a child Node still starts', + async (refused) => { + await withCleanProxyEnv(async () => { + process.env.HTTP_PROXY = proxyUrl + process.env.HTTPS_PROXY = refused + const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, HTTPS_PROXY: refused })) + try { + const child = proxyEnvironmentForChild() + // The value is still handed over — `curl` may read it — but Node, which parses these two + // names before running anything under the flag, must not be told to. + expect(child.HTTPS_PROXY).toBe(refused) + expect(child.HTTP_PROXY).toBe(proxyUrl) + expect(child).not.toHaveProperty('NODE_USE_ENV_PROXY') + // Proved on a real child rather than inferred: the same environment with the flag present + // exits before the program runs, on every Node this repository supports. + const childEnv: Record = { PATH: process.env.PATH ?? '' } + for (const [name, value] of Object.entries(child)) if (value !== undefined) childEnv[name] = value + const run = spawnSync(process.execPath, ['-e', 'process.stdout.write("started")'], { env: childEnv, encoding: 'utf8' }) + expect({ status: run.status, stdout: run.stdout }).toEqual({ status: 0, stdout: 'started' }) + } finally { + await dispose() + } + }) + }, + ) + it('keeps the outermost install\'s record of what the user exported across a nested one', async () => { await withCleanProxyEnv(async () => { // The user exported one name, in one casing. From 1fc6016f8584cef77ecad7b6339410e8859f0907 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 10:36:10 +0800 Subject: [PATCH 24/43] chore(http-proxy): match the workspace version bumped on master The 0.1.2-alpha.4 release on master touched every manifest that existed there; this package did not, so the merge left it at alpha.3 and `check-workspace-constraints` refused the tree. --- packages/util/http-proxy/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/util/http-proxy/package.json b/packages/util/http-proxy/package.json index c19c7de8fd..d185fabee7 100644 --- a/packages/util/http-proxy/package.json +++ b/packages/util/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.2-alpha.3", + "version": "0.1.2-alpha.4", "publishConfig": { "access": "public" }, From 6d261ecdeefbf0f3e0738ca58a89d592f1701fca Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 10:51:20 +0800 Subject: [PATCH 25/43] test(app-boot): assert the home .env proxy contract without pinning a casing Windows folds `https_proxy` and `HTTPS_PROXY` into one variable, so the exported spelling shadowed the file's lowercase one and the case failed there. Each spelling now gets its own name: the file supplies a name the shell did not export, and the shell outranks the file for the one it did. --- packages/boot/app-boot/tests/app-boot.spec.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index d39d38e82c..644acd4892 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -157,19 +157,22 @@ describe('loadLayeredEnv', () => { it('accepts the proxy names from the Harness-home .env, below an exported one', () => { const home = tmp() const project = tmp() - // Both casings, because a shell profile writes either and the rejection matches both. - writeFileSync(join(home, '.env'), 'HTTP_PROXY=http://from-home:8080\nhttps_proxy=http://from-home-lower:8080\nNO_PROXY=example.com\n') + // Both casings, because a shell profile writes either and the rejection matches both. Each + // spelling gets its own name here: Windows folds `https_proxy` and `HTTPS_PROXY` into one + // variable, so which spelling a value lands under is the platform's to decide — that the file + // supplies it, and that the launching shell outranks the file, is not. + writeFileSync(join(home, '.env'), 'HTTP_PROXY=http://from-home:8080\nno_proxy=example.com\nHTTPS_PROXY=http://from-home:8443\n') clear(); clearProxy() vi.stubEnv('DSH_HOME', home) vi.stubEnv('HTTPS_PROXY', 'http://exported:8080') try { const snapshot = loadLayeredEnv(NAME, project, vi.fn()) expect(snapshot.get('HTTP_PROXY')).toEqual({ value: 'http://from-home:8080', source: 'user-env', path: join(home, '.env') }) - expect(snapshot.get('https_proxy')).toEqual({ value: 'http://from-home-lower:8080', source: 'user-env', path: join(home, '.env') }) - expect(snapshot.get('NO_PROXY')?.value).toBe('example.com') - // The launching shell still outranks the file. + expect(snapshot.get('no_proxy')).toEqual({ value: 'example.com', source: 'user-env', path: join(home, '.env') }) + // The launching shell still outranks the file for the same variable. expect(snapshot.get('HTTPS_PROXY')).toEqual({ value: 'http://exported:8080', source: 'process' }) expect(process.env.HTTP_PROXY).toBe('http://from-home:8080') + expect(process.env.HTTPS_PROXY).toBe('http://exported:8080') } finally { clear(); clearProxy() vi.unstubAllEnvs() From 39b151bda38254caf089e4f527fe17d458198f51 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 2 Sep 2026 15:43:30 +0800 Subject: [PATCH 26/43] fix(issue-management): use Project-local Priority --- .agents/notes/archived/manifest.json | 3 + ...8-31-pr-opened-issue-start-dates.i18n.yaml | 4 +- .../2026-08-31-pr-opened-issue-start-dates.md | 1 + ...26-08-31-pr-opened-issue-start-dates.zh.md | 1 + ...-event-directed-pr-review-status.i18n.yaml | 4 +- ...6-08-10-event-directed-pr-review-status.md | 2 +- ...8-10-event-directed-pr-review-status.zh.md | 2 +- ...ject-local-issue-planning-fields.i18n.yaml | 6 ++ ...-02-project-local-issue-planning-fields.md | 41 ++++++++++ ...-project-local-issue-planning-fields.zh.md | 41 ++++++++++ .github/issue-management/policy.mjs | 56 ++++++++++---- .github/issue-management/policy.test.mjs | 75 +++++++++++++++++++ 12 files changed, 215 insertions(+), 21 deletions(-) rename .agents/notes/{implemented => archived}/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/process/2026-08-31-pr-opened-issue-start-dates.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-31-pr-opened-issue-start-dates.zh.md (99%) create mode 100644 .agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md create mode 100644 .agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index fa3abbea4f..e2bf423d56 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -391,6 +391,9 @@ "process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml": "sha256:dde0041399b253e3758045f0858488db8178ffc563ce889c8b396c87af6c3730", "process/2026-08-12-documentation-site-navigation-and-chrome.md": "sha256:56cb836ed862378afd33eb5c1a9dc159958b35a0aed3bf4336fcf26ab0b84b8b", "process/2026-08-12-documentation-site-navigation-and-chrome.zh.md": "sha256:f2dd4adde38a09fe312866a1e6dad0f465684d809287862f40f1a488acd4fe18", + "process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml": "sha256:c522daca5e126bf64227d6259f447648a589831125113dcf49814817d4b28f17", + "process/2026-08-31-pr-opened-issue-start-dates.md": "sha256:749f343576006b0d4950b9c67f434cea0bb9ae21a5fd8d74fefe77b59289ffbf", + "process/2026-08-31-pr-opened-issue-start-dates.zh.md": "sha256:a227dc6cba6e62d08a15b1c6b69b5941297c2d10035d257d6f3bc95d6ea54591", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", diff --git a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml similarity index 68% rename from .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml rename to .agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml index bd4d7036b2..b70dff2e32 100644 --- a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.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/process/2026-08-31-pr-opened-issue-start-dates.md -2026-08-31-pr-opened-issue-start-dates.md: 49756d9960a7616993e4513c20c990e5cfba167e -2026-08-31-pr-opened-issue-start-dates.zh.md: 752533251be559cb4fe82f619553872bbcd748d1 +2026-08-31-pr-opened-issue-start-dates.md: f17e1bf0dcfdc541952312d1b504bdaa2e2817cc +2026-08-31-pr-opened-issue-start-dates.zh.md: 3aab8f5528ad125210fbb3839fd334d99ad49bb0 diff --git a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md rename to .agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.md index 49756d9960..f17e1bf0dc 100644 --- a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.md +++ b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.md @@ -1,6 +1,7 @@ # Agent Note: PR-opened Issue start dates Status: implemented +Archived: 2026-09-02 English | [中文](2026-08-31-pr-opened-issue-start-dates.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md rename to .agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.zh.md index 752533251b..3aab8f5528 100644 --- a/.agents/notes/implemented/process/2026-08-31-pr-opened-issue-start-dates.zh.md +++ b/.agents/notes/archived/process/2026-08-31-pr-opened-issue-start-dates.zh.md @@ -1,6 +1,7 @@ # Agent Note: 在 PR 创建时设置 Issue 开始日期 Status: implemented +Archived: 2026-09-02 [English](2026-08-31-pr-opened-issue-start-dates.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml index f4cca1265e..82e3d79cac 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.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/process/2026-08-10-event-directed-pr-review-status.md -2026-08-10-event-directed-pr-review-status.md: 47f6f1731b037ae55a994c3373c0f99917da98dd -2026-08-10-event-directed-pr-review-status.zh.md: 8062ab5b1f2efdfcba92f0675af700e59a358d25 +2026-08-10-event-directed-pr-review-status.md: de4dc0700f2083772321fdf5f26c05fdf39928be +2026-08-10-event-directed-pr-review-status.zh.md: b8a8fbaa25673a376542965700e864f82ab0d739 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md index 47f6f1731b..de4dc0700f 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md @@ -16,7 +16,7 @@ The Issue lifecycle workflow treats review webhooks as commands. `pull_request.r Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status. -The status projection resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. [PR-opened Issue start dates](2026-08-31-pr-opened-issue-start-dates.md) own the separate date initialization for every same-repository Issue reference. +The status projection resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. [Project-local Issue planning fields](2026-09-02-project-local-issue-planning-fields.md) own the separate date initialization for every same-repository Issue reference. [Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review. diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md index 8062ab5b1f..b8a8fbaa25 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md @@ -16,7 +16,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review 工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。 -状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[在 PR 创建时设置 Issue 开始日期](2026-08-31-pr-opened-issue-start-dates.zh.md)负责,并处理每个同仓库 Issue 引用。 +状态投影仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。独立的日期初始化由[Project 局部 Issue 规划字段](2026-09-02-project-local-issue-planning-fields.zh.md)负责,并处理每个同仓库 Issue 引用。 [Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。 diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml new file mode 100644 index 0000000000..f2a98cb8a4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.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/process/2026-09-02-project-local-issue-planning-fields.md +2026-09-02-project-local-issue-planning-fields.md: 97689de4e64d60e40ba479b89f20dff1f557ec21 +2026-09-02-project-local-issue-planning-fields.zh.md: 4d815af1789324f82394d4d26b638c50f2031ec4 diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md new file mode 100644 index 0000000000..97689de4e6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md @@ -0,0 +1,41 @@ +# Agent Note: Project-local Issue planning fields + +Status: implemented + +English | [中文](2026-09-02-project-local-issue-planning-fields.zh.md) + +## Problem + +The Issue lifecycle workflow needs structured planning metadata, but organization Issue fields require a separate GitHub App permission from organization Projects. A workflow token with Project write access can read and update Project custom fields while GitHub rejects Issue-field reads, so using both storage systems makes one policy depend on two independently administered permission sets. + +Priority, impact, cost, and dates are used to plan work in `DSH Issue Management`. Keeping those values on the Issue also exposes them outside that Project, but the repository has no workflow that needs cross-Project values. + +## Decision + +The `DSH Issue Management` Project owns `Priority`, `Severity`, `Cost`, `Start Date`, and `Target Date` as Project custom fields. `Severity` uses the option meanings from the organization `影响面` field, and `Cost` uses the option meanings from `解决代价`. + +Repository policy resolves `Priority` and `Start Date` from the configured Project. It rejects an Issue-backed field or the wrong data type, reads Priority from the Project item, and writes Start Date through `updateProjectV2ItemFieldValue`. Organization Issue fields are retained only as `Legacy ...` migration sources and are not read by repository workflows. + +The Issue lifecycle workflow initializes `Start Date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the date only when the current Project value is empty. + +The [organization-field implementation](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md) records the superseded cross-Project ownership decision and its event-timing rationale. Event-directed Status transitions remain owned by [the lifecycle decision](2026-08-10-event-directed-pr-review-status.md). + +## Verification + +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) require Project custom fields for Priority and Start Date, cover the Shanghai date boundary, opened-only dispatch, empty-value writes, existing-value preservation, and missing Project items, and pin `updateProjectV2ItemFieldValue`. Removing an organization field requires comparing every legacy value with its Project value, including archived Project items. + +## Alternatives considered + +**Keep organization Issue fields.** They make one value visible across Projects, but the workflow does not need that scope and the GitHub App would require separate organization Issue Fields access. + +**Dual-write Issue and Project fields.** Mirrored fields retain cross-Project visibility, but every writer and manual edit can create drift and requires a reconciliation policy. + +**Process every subscribed pull-request event or overwrite Start Date.** Later events could repair missing dates, but they would assign dates after work starts or replace a manual plan. The initializer therefore retains opened-only, empty-only behavior. + +## Consequences + +Planning metadata is scoped to one Project membership. The same Issue can have different values in another Project, and an Issue outside `DSH Issue Management` has no Project-local planning values. + +The GitHub App needs Project access rather than organization Issue Fields access for policy metadata. Field renames or type changes fail the workflow instead of falling back to legacy fields. + +The empty-value read makes ordinary retries idempotent. Project field updates have no compare-and-set precondition, so simultaneous pull requests can both observe an empty Start Date and the last mutation can win. diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md new file mode 100644 index 0000000000..4d815af178 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md @@ -0,0 +1,41 @@ +# Agent Note: Project 局部 Issue 规划字段 + +Status: implemented + +[English](2026-09-02-project-local-issue-planning-fields.md) | 中文 + +## 问题 + +Issue 生命周期工作流需要结构化规划元数据,但组织 Issue 字段使用的 GitHub App 权限独立于组织 Project 权限。具有 Project 写权限的工作流 token 可以读取和更新 Project custom field,而 GitHub 会拒绝读取 Issue 字段,因此同时使用两套存储会让同一策略依赖两组独立管理的权限。 + +Priority、影响面、解决代价和日期用于在 `DSH Issue Management` 中规划工作。把这些值保存在 Issue 上还会让它们在该 Project 之外可见,但仓库没有需要跨 Project 值的工作流。 + +## 决策 + +`DSH Issue Management` Project 使用 Project custom field 存储 `Priority`、`Severity`、`Cost`、`Start Date` 和 `Target Date`。`Severity` 沿用组织字段 `影响面` 的选项含义,`Cost` 沿用 `解决代价` 的选项含义。 + +仓库策略从配置的 Project 解析 `Priority` 和 `Start Date`。策略拒绝 Issue 字段投影或错误的数据类型,从 Project item 读取 Priority,并通过 `updateProjectV2ItemFieldValue` 写入 Start Date。组织 Issue 字段仅作为带有 `Legacy ...` 前缀的迁移源保留,仓库工作流不会读取它们。 + +Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start Date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前 Project 值为空时写入日期。 + +[组织字段实现](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md)记录了已被取代的跨 Project 所有权决策及其事件时机依据。由事件直接指定的 Status 转换仍由[生命周期决策](2026-08-10-event-directed-pr-review-status.zh.md)负责。 + +## 验证 + +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)要求 Priority 和 Start Date 使用 Project custom field,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。 + +## 考虑过的替代方案 + +**保留组织 Issue 字段。** 它们可以让一个值在多个 Project 中可见,但工作流不需要该范围,并且 GitHub App 还需要单独的组织 Issue Fields 权限。 + +**同时写入 Issue 和 Project 字段。** 镜像字段保留跨 Project 可见性,但每个写入方和人工编辑都可能产生偏差,并且还需要协调策略。 + +**处理每个已订阅 PR 事件或覆盖 Start Date。** 后续事件可以修复缺失日期,但会在工作开始后才赋值或替换人工计划。因此初始化器保留仅 opened、仅空值的行为。 + +## 后果 + +规划元数据限定在一个 Project 归属中。同一个 Issue 可以在另一个 Project 中使用不同的值,`DSH Issue Management` 之外的 Issue 没有 Project 局部规划值。 + +GitHub App 通过 Project 权限而不是组织 Issue Fields 权限访问策略元数据。字段改名或类型变化会让工作流失败,而不会回退到旧字段。 + +空值读取使通常的重试保持幂等。Project 字段更新没有比较并设置前提,因此同时引用同一个 Issue 的 PR 可能都会观察到空的 Start Date,最后一次 mutation 可能胜出。 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index ef5583894f..b8c078510a 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -52,6 +52,9 @@ for (const status of ['In progress', 'In review']) { if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) { throw new Error('config.lifecycleActor 未设置') } +if (typeof config.priorityField !== 'string' || !config.priorityField) { + throw new Error('config.priorityField 未设置') +} if (typeof config.startDateField !== 'string' || !config.startDateField) { throw new Error('config.startDateField 未设置') } @@ -445,13 +448,16 @@ async function graphql(query, variables) { return result.data } -async function issueSnapshot(number, status = undefined) { +/** + * Read one Issue together with its Project planning values. + * @param {number} number Same-repository Issue number. + * @param {string|null|undefined} status Optional known Project status. + * @returns {Promise} Issue snapshot, or null when the number identifies a pull request. + */ +export async function issueSnapshot(number, status = undefined) { const issue = await api(`/repos/${config.organization}/${config.repository}/issues/${number}`) if (issue.pull_request) return null - const values = await api( - `/repos/${config.organization}/${config.repository}/issues/${number}/issue-field-values?per_page=100`, - ) - const field = (name) => values.find((value) => value.issue_field_name === name) + const context = await projectContext(number) return { number, nodeId: issue.node_id, @@ -460,8 +466,8 @@ async function issueSnapshot(number, status = undefined) { assignees: issue.assignees.map((assignee) => assignee.login), labels: issue.labels.map((label) => label.name), type: issue.type?.name ?? null, - priority: field(config.priorityField)?.single_select_option?.name ?? null, - status: status === undefined ? await projectStatus(number) : status, + priority: context.item?.priorityValue?.name ?? null, + status: status === undefined ? (context.item?.fieldValueByName?.name ?? null) : status, state: issue.state, stateReason: issue.state_reason ?? null, } @@ -476,6 +482,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa $project: Int! $includeStatusActor: Boolean! $includeStartDate: Boolean! + $priorityField: String! $startDateField: String! ) { organization(login: $organization) { @@ -484,8 +491,19 @@ async function projectContext(number, includeStatusActor = false, includeStartDa title fields(first: 50) { nodes { - ... on ProjectV2Field { id name dataType isIssueField } - ... on ProjectV2SingleSelectField { id name dataType options { id name } } + ... on ProjectV2Field { + id + name + dataType + isIssueField + } + ... on ProjectV2SingleSelectField { + id + name + dataType + isIssueField + options { id name } + } } } } @@ -510,6 +528,9 @@ async function projectContext(number, includeStatusActor = false, includeStartDa fieldValueByName(name: "Status") { ... on ProjectV2ItemFieldSingleSelectValue { name optionId } } + priorityValue: fieldValueByName(name: $priorityField) { + ... on ProjectV2ItemFieldSingleSelectValue { name optionId } + } startDateValue: fieldValueByName(name: $startDateField) @include(if: $includeStartDate) { ... on ProjectV2ItemFieldDateValue { date } @@ -526,6 +547,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa project: config.projectNumber, includeStatusActor, includeStartDate, + priorityField: config.priorityField, startDateField: config.startDateField, }, ) @@ -535,6 +557,14 @@ async function projectContext(number, includeStatusActor = false, includeStartDa if (!issue) throw new Error(`#${number} 不存在`) const statusField = project.fields.nodes.find((field) => field?.name === 'Status') if (!statusField) throw new Error('Project 缺少 Status 字段') + const priorityField = project.fields.nodes.find((field) => field?.name === config.priorityField) + if (!priorityField) throw new Error(`Project 缺少 ${config.priorityField} 字段`) + if (priorityField.dataType !== 'SINGLE_SELECT') { + throw new Error(`Project ${config.priorityField} 字段必须为 Single Select`) + } + if (priorityField.isIssueField) { + throw new Error(`Project ${config.priorityField} 字段必须为 Project custom field`) + } const startDateField = includeStartDate ? project.fields.nodes.find((field) => field?.name === config.startDateField) : null @@ -555,12 +585,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa latestStatusEvent && latestStatusEvent.status === item?.fieldValueByName?.name ? (latestStatusEvent.actor?.login ?? null) : null - return { project, issue, statusField, startDateField, item, statusActor } -} - -async function projectStatus(number) { - const context = await projectContext(number) - return context.item?.fieldValueByName?.name ?? null + return { project, issue, statusField, priorityField, startDateField, item, statusActor } } async function ensureProjectItem(number, includeStartDate = false) { @@ -579,6 +604,7 @@ async function ensureProjectItem(number, includeStartDate = false) { item: { id: data.addProjectV2ItemById.item.id, fieldValueByName: null, + priorityValue: null, startDateValue: null, }, } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 252654ce57..6f5bdbc8ba 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -5,6 +5,7 @@ import { countVisibleUnits, initializeIssueStartDate, initializePullRequestStartDates, + issueSnapshot, nextResolvingIssueStatus, parseReferences, projectDate, @@ -18,6 +19,10 @@ import { const projectGraphqlData = ({ projectItem = true, + priority = null, + priorityField = true, + priorityType = 'SINGLE_SELECT', + priorityIsIssueField = false, startDate = null, startDateField = true, startDateType = 'DATE', @@ -30,6 +35,17 @@ const projectGraphqlData = ({ fields: { nodes: [ { id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] }, + ...(priorityField + ? [ + { + id: 'priority-project-field-id', + name: 'Priority', + dataType: priorityType, + isIssueField: priorityIsIssueField, + options: [], + }, + ] + : []), ...(startDateField ? [ { @@ -54,6 +70,8 @@ const projectGraphqlData = ({ id: 'item-id', project: { id: 'project-id' }, fieldValueByName: { name: 'Inbox', optionId: 'inbox-option-id' }, + priorityValue: + priority === null ? null : { name: priority, optionId: `${priority}-option-id` }, startDateValue: startDate === null ? null : { date: startDate }, }, ] @@ -266,6 +284,43 @@ test('initializes every referenced Issue only for a PR opened event', async () = assert.equal(writes.length, 3) }) +test('reads Priority and Status from Project custom fields', async (t) => { + const previousToken = process.env.GH_TOKEN + process.env.GH_TOKEN = 'test-token' + t.after(() => { + if (previousToken === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = previousToken + }) + const urls = [] + t.mock.method(globalThis, 'fetch', async (url, options) => { + urls.push(url) + if (url.endsWith('/issues/42')) { + return Response.json({ + node_id: 'issue-id', + title: 'Project metadata', + body: null, + assignees: [], + labels: [], + type: { name: 'Task' }, + state: 'open', + state_reason: null, + }) + } + assert.equal(url, 'https://api.github.com/graphql') + assert.equal(options.headers.Authorization, 'Bearer test-token') + return Response.json({ data: projectGraphqlData({ priority: 'P1' }) }) + }) + + const issue = await issueSnapshot(42) + + assert.equal(issue.priority, 'P1') + assert.equal(issue.status, 'Inbox') + assert.deepEqual(urls, [ + 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42', + 'https://api.github.com/graphql', + ]) +}) + test('writes an empty Project Start Date with the configured field', async (t) => { const requests = mockGraphql(t, (request) => { if (request.query.includes('query(')) return projectGraphqlData() @@ -277,6 +332,8 @@ test('writes an empty Project Start Date with the configured field', async (t) = assert.equal(requests.length, 2) assert.match(requests[0].query, /isIssueField/) assert.doesNotMatch(requests[0].query, /issueField\s*\{/) + assert.match(requests[0].query, /priorityValue: fieldValueByName/) + assert.equal(requests[0].variables.priorityField, 'Priority') assert.match(requests[0].query, /ProjectV2ItemFieldDateValue/) assert.match(requests[1].query, /updateProjectV2ItemFieldValue/) assert.match(requests[1].query, /value: \{date: \$date\}/) @@ -332,6 +389,24 @@ test('rejects a missing, non-Date, or Issue-level Start Date field', async (t) = assert.equal(requests.length, 3) }) +test('rejects a missing, non-select, or Issue-level Priority field', async (t) => { + let response = projectGraphqlData({ priorityField: false }) + const requests = mockGraphql(t, () => response) + + await assert.rejects(initializeIssueStartDate(42, '2026-08-28'), /Project 缺少 Priority 字段/) + response = projectGraphqlData({ priorityType: 'TEXT' }) + await assert.rejects( + initializeIssueStartDate(42, '2026-08-28'), + /Priority 字段必须为 Single Select/, + ) + response = projectGraphqlData({ priorityIsIssueField: true }) + await assert.rejects( + initializeIssueStartDate(42, '2026-08-28'), + /Priority 字段必须为 Project custom field/, + ) + assert.equal(requests.length, 3) +}) + test('does not treat pull request references as Issue associations', () => { const references = { all: [123, 1180, 1181], From db6bdc3576c2d4e7c965e8e3ed0c2a731eed87f5 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:48:33 +0800 Subject: [PATCH 27/43] release(dsh): 0.1.2-alpha.5 --- apps/cli/package.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/acp/acp/package.json | 2 +- packages/api/gateway/package.json | 2 +- packages/api/remotes/package.json | 2 +- packages/api/session-controller/package.json | 2 +- packages/api/settings-controller/package.json | 2 +- packages/api/workspace-controller/package.json | 2 +- packages/attachment/attachment-local/package.json | 2 +- packages/attachment/attachment/package.json | 2 +- packages/boot/app-boot/package.json | 2 +- packages/boot/cmdline/package.json | 2 +- packages/bundle/acp-app/package.json | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/headless/package.json | 2 +- packages/bundle/sdk-app/package.json | 2 +- packages/bundle/sdk-minimal/package.json | 2 +- packages/bundle/web-app/package.json | 2 +- packages/client/connection/package.json | 2 +- packages/client/hmr/package.json | 2 +- packages/client/locale/package.json | 2 +- packages/client/modules/package.json | 2 +- packages/client/store/package.json | 2 +- packages/client/ui-agent-preset/package.json | 2 +- packages/client/ui-approval/package.json | 2 +- packages/client/ui-attachment/package.json | 2 +- packages/client/ui-brand-official/package.json | 2 +- packages/client/ui-chat/package.json | 2 +- packages/client/ui-commands/package.json | 2 +- packages/client/ui-conversation/package.json | 2 +- packages/client/ui-deliverables/package.json | 2 +- packages/client/ui-directory-picker-browse/package.json | 2 +- packages/client/ui-directory-picker-native/package.json | 2 +- packages/client/ui-goal/package.json | 2 +- packages/client/ui-input-trigger/package.json | 2 +- packages/client/ui-jobs/package.json | 2 +- packages/client/ui-layout/package.json | 2 +- packages/client/ui-message-feedback/package.json | 2 +- packages/client/ui-model-selection/package.json | 2 +- packages/client/ui-permission-presets/package.json | 2 +- packages/client/ui-plan/package.json | 2 +- packages/client/ui-primitives/package.json | 2 +- packages/client/ui-reference/package.json | 2 +- packages/client/ui-renderer/package.json | 2 +- packages/client/ui-schedule/package.json | 2 +- packages/client/ui-session/package.json | 2 +- packages/client/ui-settings-general/package.json | 2 +- packages/client/ui-settings-models/package.json | 2 +- packages/client/ui-settings-plugin-inventory/package.json | 2 +- packages/client/ui-settings-plugins/package.json | 2 +- packages/client/ui-settings/package.json | 2 +- packages/client/ui-sidebar/package.json | 2 +- packages/client/ui-skill/package.json | 2 +- packages/client/ui-slots/package.json | 2 +- packages/client/ui-subagent/package.json | 2 +- packages/client/ui-theme/package.json | 2 +- packages/client/ui-tool/package.json | 2 +- packages/client/ui-trajectory/package.json | 2 +- packages/client/ui-user-questions/package.json | 2 +- packages/client/ui-workflow-run/package.json | 2 +- packages/client/ui-workspace/package.json | 2 +- packages/client/web/package.json | 2 +- packages/code-runtime/code-runtime-worker-thread/package.json | 2 +- packages/code-runtime/code-runtime/package.json | 2 +- packages/compaction/command-compact/package.json | 2 +- packages/compaction/compaction-basic/package.json | 2 +- packages/compaction/compaction-tool-result-pruner/package.json | 2 +- packages/compaction/compaction/package.json | 2 +- packages/context/agent-instructions/package.json | 2 +- packages/context/file-reference-local/package.json | 2 +- packages/context/file-reference/package.json | 2 +- packages/context/session-reference/package.json | 2 +- packages/context/time-context/package.json | 2 +- packages/context/tmux-context/package.json | 2 +- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-loop/package.json | 2 +- packages/core/agent-tool-presentation/package.json | 2 +- packages/core/agent/package.json | 2 +- packages/core/scope/package.json | 2 +- packages/core/session/package.json | 2 +- packages/core/system-prompt/package.json | 2 +- packages/core/tools/package.json | 2 +- packages/credentials/authorization/package.json | 2 +- packages/credentials/credentials-local/package.json | 2 +- packages/credentials/credentials/package.json | 2 +- packages/e2b/e2b/package.json | 2 +- packages/e2b/fs-e2b/package.json | 2 +- packages/e2b/subprocess-e2b/package.json | 2 +- packages/experimental/agent-team-profile/package.json | 2 +- packages/experimental/agent-team-web-profile/package.json | 2 +- packages/experimental/agent-team/package.json | 2 +- packages/experimental/client-ui-agent-team/package.json | 2 +- packages/experimental/code-runtime-python/package.json | 2 +- packages/experimental/inspector/package.json | 2 +- packages/experimental/tool-agent-team/package.json | 2 +- packages/experimental/webworker-packer/package.json | 2 +- packages/experimental/webworker-runtime/package.json | 2 +- packages/extensions/cordis-client-runner/package.json | 2 +- packages/extensions/cordis-host-runner/package.json | 2 +- packages/extensions/tool-cordis/package.json | 2 +- packages/extensions/ui-cordis/package.json | 2 +- packages/feedback/command-feedback/package.json | 2 +- packages/feedback/message-feedback/package.json | 2 +- packages/fs/fs-local/package.json | 2 +- packages/fs/fs-observation-policy/package.json | 2 +- packages/fs/fs-sandbox/package.json | 2 +- packages/fs/fs/package.json | 2 +- packages/fs/tool-fs-search/package.json | 2 +- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-str-replace-editor/package.json | 2 +- packages/goal/command-goal/package.json | 2 +- packages/goal/goal-round-driver/package.json | 2 +- packages/goal/goal/package.json | 2 +- packages/goal/tool-goal/package.json | 2 +- packages/guard/repeat-tool-reminder/package.json | 2 +- packages/guard/timeout-policy/package.json | 2 +- packages/hooks/hook-protocol/package.json | 2 +- packages/hooks/hooks-claude-code/package.json | 2 +- packages/hooks/hooks-codex/package.json | 2 +- packages/host/directory-picker-auto/package.json | 2 +- packages/host/directory-picker-browse/package.json | 2 +- packages/host/directory-picker-native/package.json | 2 +- packages/host/directory-picker/package.json | 2 +- packages/host/frontend-static/package.json | 2 +- packages/host/plugin-inventory/package.json | 2 +- packages/host/webserver/package.json | 2 +- packages/identity/anonymous-user-id/package.json | 2 +- packages/interaction/commands/package.json | 2 +- packages/interaction/permission-presets/package.json | 2 +- packages/interaction/tool-ask-user/package.json | 2 +- packages/interaction/user-approval/package.json | 2 +- packages/interaction/user-questions/package.json | 2 +- packages/jobs/jobs-local/package.json | 2 +- packages/jobs/jobs/package.json | 2 +- packages/jobs/tool-jobs/package.json | 2 +- packages/llm/deepseek-llm-api-extensions/package.json | 2 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-retry/package.json | 2 +- packages/llm/llm/package.json | 2 +- packages/llm/plugin-package-inventory-deepseek/package.json | 2 +- packages/llm/token-meter/package.json | 2 +- packages/lsp/lsp-stdio/package.json | 2 +- packages/lsp/lsp/package.json | 2 +- packages/lsp/tool-lsp/package.json | 2 +- packages/mcp/mcp-client/package.json | 2 +- packages/plan/plan-mode/package.json | 2 +- packages/preset/agent-presets/package.json | 2 +- packages/preset/persona/package.json | 2 +- packages/runtime-diagnostics/invariants/package.json | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-policy/package.json | 2 +- packages/sandbox/sandbox-windows-acl/package.json | 2 +- packages/sandbox/sandbox/package.json | 2 +- packages/schedule/schedule/package.json | 2 +- packages/sdk/client/package.json | 2 +- packages/sdk/protocol/package.json | 2 +- packages/sdk/server/package.json | 2 +- packages/session-query/session-log-export/package.json | 2 +- packages/session-query/session-query-sqlite/package.json | 2 +- packages/session-query/session-query/package.json | 2 +- packages/session-query/tool-session-query/package.json | 2 +- packages/session/session-checkpoint-policy/package.json | 2 +- packages/session/session-log-deepseek/package.json | 2 +- packages/session/session-persistence-jsonl/package.json | 2 +- packages/session/session-persistence/package.json | 2 +- packages/session/session-projection-cache/package.json | 2 +- packages/session/session-projection/package.json | 2 +- packages/session/session-stats/package.json | 2 +- packages/session/session-telemetry-otel/package.json | 2 +- packages/session/session-telemetry/package.json | 2 +- packages/session/session-title-all-prompts-llm/package.json | 2 +- packages/session/session-title-first-prompt-llm/package.json | 2 +- packages/session/session-title-llm/package.json | 2 +- packages/session/session-title/package.json | 2 +- packages/session/session-turn-outline/package.json | 2 +- packages/settings/settings-file/package.json | 2 +- packages/settings/settings/package.json | 2 +- packages/shell/bash-local/package.json | 2 +- packages/shell/bash-sandbox/package.json | 2 +- packages/shell/pwsh-local/package.json | 2 +- packages/shell/pwsh-sandbox/package.json | 2 +- packages/shell/shell-env/package.json | 2 +- packages/shell/shell/package.json | 2 +- packages/shell/tool-bash-persistent/package.json | 2 +- packages/shell/tool-bash/package.json | 2 +- packages/shell/tool-pwsh-persistent/package.json | 2 +- packages/shell/tool-pwsh/package.json | 2 +- packages/skill/skill-badge/package.json | 2 +- packages/skill/skill-filesystem/package.json | 2 +- packages/skill/skill/package.json | 2 +- packages/skill/tool-skill/package.json | 2 +- packages/spill/spill-local/package.json | 2 +- packages/spill/spill-policy/package.json | 2 +- packages/spill/spill/package.json | 2 +- packages/storage/storage-domain/package.json | 2 +- packages/storage/storage-json/package.json | 2 +- packages/storage/storage-sqlite/package.json | 2 +- packages/storage/storage/package.json | 2 +- packages/subagent/subagent-acp/package.json | 2 +- packages/subagent/subagent-claude-code/package.json | 2 +- packages/subagent/subagent-codex/package.json | 2 +- packages/subagent/subagent-dsh-sdk/package.json | 2 +- packages/subagent/subagent-fork-in-process/package.json | 2 +- packages/subagent/subagent-in-process-driver/package.json | 2 +- packages/subagent/subagent-spawn-in-process/package.json | 2 +- packages/subagent/subagent/package.json | 2 +- packages/subagent/tool-subagent-control/package.json | 2 +- packages/subagent/tool-subagent/package.json | 2 +- packages/subprocess/subprocess-local/package.json | 2 +- packages/subprocess/subprocess/package.json | 2 +- packages/subprocess/win32-process/package.json | 2 +- packages/terminal/terminal-bash/package.json | 2 +- packages/terminal/terminal/package.json | 2 +- packages/terminal/tool-terminal/package.json | 2 +- packages/test-support/agent-loop-testkit/package.json | 2 +- packages/test-support/client-runtime/package.json | 2 +- packages/test-support/llm-mock-server/package.json | 2 +- packages/test-support/llm-replay/package.json | 2 +- packages/test-support/loader-smoke/package.json | 2 +- packages/test-support/session-snapshot/package.json | 2 +- packages/todo/tool-todo/package.json | 2 +- packages/typert/generator/package.json | 2 +- packages/typert/loader/package.json | 2 +- packages/typert/protocol/package.json | 2 +- packages/typert/registry/package.json | 2 +- packages/util/atomic-write/package.json | 2 +- packages/util/brand/package.json | 2 +- packages/util/crypto/package.json | 2 +- packages/util/deque/package.json | 2 +- packages/util/home-paths/package.json | 2 +- packages/util/launch-environment/package.json | 2 +- packages/util/native-command/package.json | 2 +- packages/util/output-retention/package.json | 2 +- packages/util/time/package.json | 2 +- packages/util/timeout/package.json | 2 +- packages/util/values/package.json | 2 +- packages/util/workspace-path/package.json | 2 +- packages/web/tool-web/package.json | 2 +- packages/web/web-fetch-http/package.json | 2 +- packages/web/web-search-deepseek/package.json | 2 +- packages/web/web-search-exa/package.json | 2 +- packages/web/web-search-perplexity/package.json | 2 +- packages/web/web/package.json | 2 +- packages/webhook/webhook-github/package.json | 2 +- packages/webhook/webhook/package.json | 2 +- packages/workflow/tool-ralph/package.json | 2 +- packages/workflow/tool-workflow/package.json | 2 +- packages/workflow/workflow-worker-thread/package.json | 2 +- packages/workflow/workflow/package.json | 2 +- packages/workspace/workspace/package.json | 2 +- 252 files changed, 252 insertions(+), 252 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index ac50f4864a..9a17e1dcfb 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/apps/web/package.json b/apps/web/package.json index e65aea0049..633c223758 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/package.json b/package.json index 78502c93f2..0c4a164fc0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "license": "MIT", "private": true, "type": "module", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 6040163821..5e8946c6eb 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 41d7c3b9b2..5f125b33e2 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 5940ea9594..a60099926e 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly for application-selected Host capabilities", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index b1fe281a9e..ed3e7101f7 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-session-controller", "description": "Session Remote commands, cold reads, and live control transport", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index 56602b6c58..f3eb373993 100644 --- a/packages/api/settings-controller/package.json +++ b/packages/api/settings-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-settings-controller", "description": "Remote owner for the configuration surfaces over the settings-domain seams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 0a64db9665..4fcec35476 100644 --- a/packages/api/workspace-controller/package.json +++ b/packages/api/workspace-controller/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-workspace-controller", "description": "Workspace Remote commands and reconnect-safe state transport", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index e04af21abd..f249b1f5b5 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 64220b8e77..bd102976ed 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 4ec69c6079..27540bc343 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 0fe1c084c5..14e424df1f 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index 56604d34c8..af9dab2635 100644 --- a/packages/bundle/acp-app/package.json +++ b/packages/bundle/acp-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp-app", "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index ebc86eb5eb..e4d0227451 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 6a9785ddeb..10f7fd5816 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index e7a97c337c..b0601999e5 100644 --- a/packages/bundle/sdk-app/package.json +++ b/packages/bundle/sdk-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-app", "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index fa99da9163..7e568dfba7 100644 --- a/packages/bundle/sdk-minimal/package.json +++ b/packages/bundle/sdk-minimal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-minimal", "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 77e803c655..63f32d737e 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 9214c8eaa9..d70fe8cbba 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Authenticated RPC transport, generation lifecycle, and browser fixture", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 2e23f9fc4d..dd5fb1ab49 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 1de5c36ad5..21746815d5 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed preference, extensible language catalog, browser fallback, and typed built-in dictionaries", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index f9349f31a3..7a6571a167 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index a0d44bd70f..c70d9b4f8e 100644 --- a/packages/client/store/package.json +++ b/packages/client/store/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-store", "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 3f7a158a1b..797bf1c6fb 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index e79953516a..7a4a01862d 100644 --- a/packages/client/ui-approval/package.json +++ b/packages/client/ui-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-approval", "description": "Approval composer takeover over the scoped Remote Event waterfall", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index fb6d39bf65..c017a58f02 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index 62ec39a204..ae4c31086a 100644 --- a/packages/client/ui-brand-official/package.json +++ b/packages/client/ui-brand-official/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-brand-official", "description": "Official DeepSeek Harness brand occupants for the Web client's sidebar slots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 8226e45380..1b4a90f5f6 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-chat", "description": "Chat Conversation target, node definitions, renderers, and details surface", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index c7c0448ee9..800fc2f8be 100644 --- a/packages/client/ui-commands/package.json +++ b/packages/client/ui-commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-commands", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index a8e73146af..4c45fd9e12 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Target-neutral Conversation assembly, shell, composer, queue, and view navigation", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 44f7248e7d..ad99298a05 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail and clickable final-response file references for Web", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index a30e4b90cc..36aa15cc6a 100644 --- a/packages/client/ui-directory-picker-browse/package.json +++ b/packages/client/ui-directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-browse", "description": "In-app directory browsing surface: the workspace directory-flow owner rendering the host's listing and creation primitives", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 470c829b35..0b039464ff 100644 --- a/packages/client/ui-directory-picker-native/package.json +++ b/packages/client/ui-directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-directory-picker-native", "description": "Native directory-picker surface: the renderless workspace directory-flow occupant driving the host's OS chooser", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index b7d28df51b..ee664305f1 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 8e87e0dec6..69cf3fb724 100644 --- a/packages/client/ui-input-trigger/package.json +++ b/packages/client/ui-input-trigger/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-input-trigger", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index d3e3c31030..08b7c20976 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 4a63c4e968..a67128f5b6 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index b2d0807e12..fdf391f56a 100644 --- a/packages/client/ui-message-feedback/package.json +++ b/packages/client/ui-message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-message-feedback", "description": "Per-message feedback controls contributed to the assistant-message action strip, backed by the messageFeedback Host Remote", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index 84b4f3b5c7..d4b49856fb 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model-selection", "description": "Model selection over the shared model catalog, Session projection, and session.selectModel", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index c05bd8d894..61d0c653dd 100644 --- a/packages/client/ui-permission-presets/package.json +++ b/packages/client/ui-permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-permission-presets", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index fd5a2dadc2..2c9dfc5684 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 3206005953..a2499cff0e 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 20fdda586d..6efa9c8910 100644 --- a/packages/client/ui-reference/package.json +++ b/packages/client/ui-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-reference", "description": "Unified Web @file and @session reference source", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 8d49ea11d4..294a8dc1c2 100644 --- a/packages/client/ui-renderer/package.json +++ b/packages/client/ui-renderer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-renderer", "description": "Browser UI renderer: React slot bindings, ctx.uiRenderer, and the assembled application root", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 7391fe6551..7eb16b43c9 100644 --- a/packages/client/ui-schedule/package.json +++ b/packages/client/ui-schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-schedule", "description": "Read-only active Schedule catalog in the Web Session header", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index c1aa8090a3..9694f67383 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-session", "description": "Session Controller adapter for React and session-scoped slots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 24cf297b7d..e3a6c50c9b 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 6c983d881f..2d558c536b 100644 --- a/packages/client/ui-settings-models/package.json +++ b/packages/client/ui-settings-models/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-models", "description": "Models settings and shared product-onboarding dialogs over existing settings and credential joins", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 0ee4218292..6d8f08385e 100644 --- a/packages/client/ui-settings-plugin-inventory/package.json +++ b/packages/client/ui-settings-plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugin-inventory", "description": "Read-only Cordis Loader inventory tab in Web Plugins settings", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index c48afbddeb..847a79b1c5 100644 --- a/packages/client/ui-settings-plugins/package.json +++ b/packages/client/ui-settings-plugins/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-plugins", "description": "Plugins settings section with feature-owned tabs and configurable host-plane plugin cards", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index eba8fb5434..29d3b1b148 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings domain base plugin: the settings-namespace scope service and the canonical settings slot-type contract", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 1c82545cee..f2f858c502 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index d95faad9ef..1966bb26b5 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 741a154846..efaf783795 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index e93425b433..056479aa79 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 7b927856ec..de0f11452d 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeRuntime for light/dark/system state; --dsw-* token styles and Appearance settings row", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index d745d005c5..452b249f2b 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 24c2906800..a7b7b4a886 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index a85dc2a477..684201142a 100644 --- a/packages/client/ui-user-questions/package.json +++ b/packages/client/ui-user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-user-questions", "description": "Web ask_user_question composer takeover and plan-review presentation UI", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 27151a3dc1..9df135f57e 100644 --- a/packages/client/ui-workflow-run/package.json +++ b/packages/client/ui-workflow-run/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workflow-run", "description": "Durable workflow-run Conversation Node and nested member disclosure for dsh web", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 3ae0b8cc27..e3bc1516e8 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 1d73253e9b..2385f95eb4 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web boot kernel: static module table, Cordis loader, framework-free boot page, and UI-renderer handoff", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime-worker-thread/package.json b/packages/code-runtime/code-runtime-worker-thread/package.json index 7e4a5f9267..2c0c34ddd5 100644 --- a/packages/code-runtime/code-runtime-worker-thread/package.json +++ b/packages/code-runtime/code-runtime-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker-thread", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index f458ee2162..3ac8030104 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index 167a207365..0bf21a87f6 100644 --- a/packages/compaction/command-compact/package.json +++ b/packages/compaction/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index d14a54e685..75d7ad032e 100644 --- a/packages/compaction/compaction-basic/package.json +++ b/packages/compaction/compaction-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index a02e77184b..b0ff0b4ded 100644 --- a/packages/compaction/compaction-tool-result-pruner/package.json +++ b/packages/compaction/compaction-tool-result-pruner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction-tool-result-pruner", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index 3d9c756297..3183dc3745 100644 --- a/packages/compaction/compaction/package.json +++ b/packages/compaction/compaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compaction", "description": "Abstract compaction service seam (ctx.compaction) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 96ec478374..44e0e1c7fb 100644 --- a/packages/context/agent-instructions/package.json +++ b/packages/context/agent-instructions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-instructions", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index 19869fb842..b89cd44d74 100644 --- a/packages/context/file-reference-local/package.json +++ b/packages/context/file-reference-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference-local", "description": "Local-filesystem ctx.fileReferences provider with bounded fuzzy indexes", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index a01b6fea8a..4dd8363b11 100644 --- a/packages/context/file-reference/package.json +++ b/packages/context/file-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-file-reference", "description": "File-reference discovery contract and shared @file grammar", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index ec2755683c..9b8e2ce0d8 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferenceResolver)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 3bb8e9f281..c50f407a3f 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 3e35684111..bc756fa9bc 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 953074cb37..506f14cc5f 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 3df5296e30..015bb7e1e1 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 9b553895ed..90c1675d1f 100644 --- a/packages/core/agent-tool-presentation/package.json +++ b/packages/core/agent-tool-presentation/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-tool-presentation", "description": "Agent-plane presentation selector: composes one agent's tools as PTC mode, native, or both", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 49b8612baf..d57cfedc13 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 88dcf337e6..1dc076fa5e 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 4d4805f4b6..04101b1bb9 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 312e27bafa..5c697c64e8 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index b22274e4aa..8cbbb477b6 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index eec2248f0b..9d18e09b94 100644 --- a/packages/credentials/authorization/package.json +++ b/packages/credentials/authorization/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-authorization", "description": "Authorization seam (ctx.authorization): plugin-owned flows that obtain a credential through a conversation with the human", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 006e862171..d907a56873 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index d1725fc989..d5fc47d473 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index c25b5dcbf8..a50525d332 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 5b9a76bdb3..ca7844f726 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index ab11e46bf3..bc3bf1b4f6 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index b0fff7c17c..342814bed4 100644 --- a/packages/experimental/agent-team-profile/package.json +++ b/packages/experimental/agent-team-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-profile", "description": "Private profile bundle enabling Agent Teams over dsh-base", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team-web-profile/package.json b/packages/experimental/agent-team-web-profile/package.json index 32be82b680..00d32cea02 100644 --- a/packages/experimental/agent-team-web-profile/package.json +++ b/packages/experimental/agent-team-web-profile/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team-web-profile", "description": "Private Web profile layer for Agent Teams Remote and UI plugins", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 35ba66e338..75539a8169 100644 --- a/packages/experimental/agent-team/package.json +++ b/packages/experimental/agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-agent-team", "description": "Implicit-root Agent Teams roster, durable peer mailbox, and shared task DAG", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/client-ui-agent-team/package.json b/packages/experimental/client-ui-agent-team/package.json index 7e03052ea9..2d40a2410c 100644 --- a/packages/experimental/client-ui-agent-team/package.json +++ b/packages/experimental/client-ui-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-client-ui-agent-team", "description": "Web Agent Teams roster, task board, and teammate navigation", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/code-runtime-python/package.json b/packages/experimental/code-runtime-python/package.json index 188128bf50..f79af60424 100644 --- a/packages/experimental/code-runtime-python/package.json +++ b/packages/experimental/code-runtime-python/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-code-runtime-python", "description": "CPython subprocess implementation of the DeepSeek Harness code-execution seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "repository": { "type": "git", "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", diff --git a/packages/experimental/inspector/package.json b/packages/experimental/inspector/package.json index 96363ede0b..27d5fc5e50 100644 --- a/packages/experimental/inspector/package.json +++ b/packages/experimental/inspector/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-inspector", "description": "Experimental cross-realm CDP hub for Host debugging and Client Runtime inspection", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index e2b012d1ba..92e59e8d1c 100644 --- a/packages/experimental/tool-agent-team/package.json +++ b/packages/experimental/tool-agent-team/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-tool-agent-team", "description": "Scoped model-facing Agent Teams tools over ctx.agentTeams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index 61837e941b..5a0195dc72 100644 --- a/packages/experimental/webworker-packer/package.json +++ b/packages/experimental/webworker-packer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-packer", "description": "Build-time packer for the browser runtime's base VFS image and ordered data-overlay archives", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index 57b33b5d22..f16e02acd2 100644 --- a/packages/experimental/webworker-runtime/package.json +++ b/packages/experimental/webworker-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-experimental-webworker-runtime", "description": "Browser-only harness runtime: in-memory VFS, module transform and loader, postMessage tunnel, and the dedicated Web Worker assembly, with the Node-compatibility layer that lets the host tree run unchanged", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "private": true, "repository": { "type": "git", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 1236cfac34..1ee8087acd 100644 --- a/packages/extensions/cordis-client-runner/package.json +++ b/packages/extensions/cordis-client-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-client-runner", "description": "Browser half of dynamic dual-half plugin packages: event subscription, closure evaluation, guard facade, and loader entries", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index d53f017417..355ea66d9f 100644 --- a/packages/extensions/cordis-host-runner/package.json +++ b/packages/extensions/cordis-host-runner/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cordis-host-runner", "description": "Dynamic package definition registry, host-half sandbox lifecycle, and invoke handler table for model-mounted dual-half packages", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 855e6aedd1..6891f808b0 100644 --- a/packages/extensions/tool-cordis/package.json +++ b/packages/extensions/tool-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index a2b1207a37..0c241fbef6 100644 --- a/packages/extensions/ui-cordis/package.json +++ b/packages/extensions/ui-cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-cordis", "description": "Cordis dynamic-plugin definition card: the keyed cordis_define tool row with its run/stop switch", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 1b6b1e869b..ab845a4683 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index 594bb6a355..a30567d0ce 100644 --- a/packages/feedback/message-feedback/package.json +++ b/packages/feedback/message-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-message-feedback", "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 63c975a09a..6b00f5a4f5 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 09c3492c3b..3379fea8ec 100644 --- a/packages/fs/fs-observation-policy/package.json +++ b/packages/fs/fs-observation-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-observation-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service API)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 3b496f360e..45308cf4e2 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 2584772eb8..138fa2d462 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 776f129dc1..14097343a1 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index bd6c0fbb40..e5e42d1022 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index cb89959c89..a849114c88 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 36bafa1d0b..d386516512 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index 4169d63dd7..fc24c5ede4 100644 --- a/packages/goal/goal-round-driver/package.json +++ b/packages/goal/goal-round-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-round-driver", "description": "Race-fenced same-session goal-round driver", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 2770c53221..7ec02573e0 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index b35bc1bf14..8f6b99d23a 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 3ce8a31a8e..9dd322c991 100644 --- a/packages/guard/repeat-tool-reminder/package.json +++ b/packages/guard/repeat-tool-reminder/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-repeat-tool-reminder", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 82e4eda1f4..b0889d7783 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-call-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 1bc5a5cb71..c1c04f3be7 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index fe735edd9a..ea853e8fa3 100644 --- a/packages/hooks/hooks-claude-code/package.json +++ b/packages/hooks/hooks-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-claude-code", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index f0c96eeb14..8477fa83ac 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index a4cf3d86ef..01c5fcf132 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 76fb715690..ba37329049 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index 06044425ae..d88ea47576 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index fbf77d3b35..02a7d650d6 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index bb32254def..05886f83f9 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving explicit index entries and static assets with traversal rejection and 404 misses", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index 0bc31715d8..eafa27d9da 100644 --- a/packages/host/plugin-inventory/package.json +++ b/packages/host/plugin-inventory/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-plugin-inventory", "description": "Read-only Remote projection of current Cordis Loader plugin state", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 9bade860f3..aef01f1bab 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index 8489124caf..8035a9457d 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 6900a383c2..790212b124 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index fd6cb5b225..23fdd4ba05 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index b76839104e..ed671365c1 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 3983fa725b..f733cf7570 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index a9c32ec8f0..4d02e66f6e 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index f2d9acda50..09a1520696 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 2c03ff7e48..c074160ab5 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index c9893a441e..b89b55e60f 100644 --- a/packages/jobs/tool-jobs/package.json +++ b/packages/jobs/tool-jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-jobs", "description": "Model-facing background job control tools (job_output, job_list, job_kill) over the ctx.jobs registry", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index 97c360d6f4..b3e96291e0 100644 --- a/packages/llm/deepseek-llm-api-extensions/package.json +++ b/packages/llm/deepseek-llm-api-extensions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deepseek-llm-api-extensions", "description": "Additive request-field registry for the official DeepSeek LLM API adapter", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index a6978971ce..d12c430993 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 923654f98e..e2917f17a4 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index f1456d143e..08ff973ee6 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 15aa6cddf4..f812c8bd75 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 3af4e53894..9d708e0a32 100644 --- a/packages/llm/plugin-package-inventory-deepseek/package.json +++ b/packages/llm/plugin-package-inventory-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plugin-package-inventory-deepseek", "description": "Active Loader-backed plugin package inventory for official DeepSeek LLM API requests", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 0fe423246f..b59599b402 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index 3d5b3bea02..ce9eefb609 100644 --- a/packages/lsp/lsp-stdio/package.json +++ b/packages/lsp/lsp-stdio/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp-stdio", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 8a1cbf9972..3f11633c2d 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index d31a6465f5..d5967c657d 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index badc29437d..181c9f5dbb 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index a28bf79dd9..6137699f07 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index ffda101c80..6f6f9805c2 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 67480ddd74..71ad037cb1 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index 4a6ecf7d2a..df096f1840 100644 --- a/packages/runtime-diagnostics/invariants/package.json +++ b/packages/runtime-diagnostics/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 6135d81948..fc79a97130 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index aca9b6b291..1a3b7d3f3c 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 92569fbcc2..e8e7101765 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 0a55d592b1..6d0e99fa9a 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index a91310aba1..19282525c2 100644 --- a/packages/schedule/schedule/package.json +++ b/packages/schedule/schedule/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-schedule", "description": "Agent-scoped durable after, at, and fixed-rate reminders over the session event log", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index 806593fe8b..e8e140390f 100644 --- a/packages/sdk/client/package.json +++ b/packages/sdk/client/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 854fd9ec48..9aa58ad3d3 100644 --- a/packages/sdk/protocol/package.json +++ b/packages/sdk/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index 163217d352..f01c1c2c9d 100644 --- a/packages/sdk/server/package.json +++ b/packages/sdk/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-sdk-jsonrpc-server", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 468e59ffb8..bf51c0372e 100644 --- a/packages/session-query/session-log-export/package.json +++ b/packages/session-query/session-log-export/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-export", "description": "Web Session-log export command and shared download dialog", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index b637b13a7e..b1c6b88234 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 14013226c3..54fdafea1f 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index e6291db04a..2b064c1f92 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 7f3533befe..fbd84ee9b6 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index a5cad9a5d8..898d840483 100644 --- a/packages/session/session-log-deepseek/package.json +++ b/packages/session/session-log-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-log-deepseek", "description": "Incremental lossless session-log request extension for the official DeepSeek LLM API", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index a3bd097d83..6825b00c2e 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 1b4802d57c..580406aa93 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 73ce9ea58d..8b5ebdb655 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session checkpoint records on the session_projcache storage domain (per-record layout), throttled write-behind, and the cached listing read", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 547fc90ed1..7abf8292a6 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index d8c9fb3804..a2290391aa 100644 --- a/packages/session/session-stats/package.json +++ b/packages/session/session-stats/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-stats", "description": "Whole-log conversation counts and wall times projection (sessionStats) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index b7b306e510..53cbe5d05a 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 65f4c5b478..a2acebfe6b 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "SessionTelemetryBackend seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-all-prompts-llm/package.json b/packages/session/session-title-all-prompts-llm/package.json index 197b4ee8bb..bfda2819f5 100644 --- a/packages/session/session-title-all-prompts-llm/package.json +++ b/packages/session/session-title-all-prompts-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-all-prompts-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-first-prompt-llm/package.json b/packages/session/session-title-first-prompt-llm/package.json index bc64fbf187..759d25c917 100644 --- a/packages/session/session-title-first-prompt-llm/package.json +++ b/packages/session/session-title-first-prompt-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-prompt-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 949f393365..cc5260d597 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 4c6bc9999c..4796c2405e 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index f50b13ca8c..455fdace74 100644 --- a/packages/session/session-turn-outline/package.json +++ b/packages/session/session-turn-outline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-turn-outline", "description": "Whole-log turn outline projection (turnOutline) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 1fac8b8c7d..26a747974c 100644 --- a/packages/settings/settings-file/package.json +++ b/packages/settings/settings-file/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-file", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index f370cc7636..a82cbca836 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index d89cc6ebe1..2463cbc122 100644 --- a/packages/shell/bash-local/package.json +++ b/packages/shell/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index 43b3a18aec..fc82e1c504 100644 --- a/packages/shell/bash-sandbox/package.json +++ b/packages/shell/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index 241ed04e89..cc57761bd3 100644 --- a/packages/shell/pwsh-local/package.json +++ b/packages/shell/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index eb1f323fc4..07611b9efc 100644 --- a/packages/shell/pwsh-sandbox/package.json +++ b/packages/shell/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 4b2bfce68b..2ea30cb7b8 100644 --- a/packages/shell/shell-env/package.json +++ b/packages/shell/shell-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index a2fd9d37cf..6571e6c3e5 100644 --- a/packages/shell/shell/package.json +++ b/packages/shell/shell/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-shell", "description": "Abstract bash executor seam (ctx.shell) for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 42b1958e25..803139af30 100644 --- a/packages/shell/tool-bash-persistent/package.json +++ b/packages/shell/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 68774b0c49..2db6c305bf 100644 --- a/packages/shell/tool-bash/package.json +++ b/packages/shell/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-job and sandbox-escalation support", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index 4cd2cabc85..f28de881bd 100644 --- a/packages/shell/tool-pwsh-persistent/package.json +++ b/packages/shell/tool-pwsh-persistent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh-persistent", "description": "Model-facing owner-scoped persistent PowerShell tool backed by the Harness PTY service", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index 83ad8bc0e4..fc3f61498a 100644 --- a/packages/shell/tool-pwsh/package.json +++ b/packages/shell/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 662f2fbc52..5400420046 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 90204f193f..26589bcc4e 100644 --- a/packages/skill/skill-filesystem/package.json +++ b/packages/skill/skill-filesystem/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-filesystem", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 0691320c01..396bade970 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index da592a0ec5..161cee3dc8 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index e2e9ee1427..3cb3f4dd8d 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index bec3f69135..c0c0c22650 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service API)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index d61cf92ffa..e71913a546 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 64b233b49c..0546ce688b 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index a3bbc4be5d..fb0a1c9c59 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index d3e708627d..c50a81eea7 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 9abe2460a5..be1fb943d8 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index bb050d095b..c7393cf1a4 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 7abbd315d1..15ecefc5df 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 0524fa8cfe..322fea2317 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 43adf4c56f..74d87e76f5 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 62fd0f37c0..82dc185678 100644 --- a/packages/subagent/subagent-fork-in-process/package.json +++ b/packages/subagent/subagent-fork-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-fork-in-process", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index dd1c59b1e6..a96b44c9ad 100644 --- a/packages/subagent/subagent-in-process-driver/package.json +++ b/packages/subagent/subagent-in-process-driver/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-in-process-driver", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index 86485a17b6..e09a8123f2 100644 --- a/packages/subagent/subagent-spawn-in-process/package.json +++ b/packages/subagent/subagent-spawn-in-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn-in-process", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index b19a2ae48c..05be0b467a 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index b5d2c05742..25cd9bbb34 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 34318e3aa1..05367cbad1 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 70663bb503..618e26f29e 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 92cfa8131c..f44d031797 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index c90551d1d4..8263d99b72 100644 --- a/packages/subprocess/win32-process/package.json +++ b/packages/subprocess/win32-process/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-win32-process", "description": "Low-level Win32 process, stdio, and Job Object primitives for the DeepSeek Harness Windows sandbox", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index fbdb39ff0b..978ed1318a 100644 --- a/packages/terminal/terminal-bash/package.json +++ b/packages/terminal/terminal-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal-bash", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 02cd59fba0..965166a540 100644 --- a/packages/terminal/terminal/package.json +++ b/packages/terminal/terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-terminal", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index bb75c1ae2d..417de38632 100644 --- a/packages/terminal/tool-terminal/package.json +++ b/packages/terminal/tool-terminal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-terminal", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-job integration", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index 33dad665e7..cd464acfc7 100644 --- a/packages/test-support/agent-loop-testkit/package.json +++ b/packages/test-support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index acf24e1e33..2a67c094e2 100644 --- a/packages/test-support/client-runtime/package.json +++ b/packages/test-support/client-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotRegistry + UI renderer with test-owned session/workspace doubles for feature specs", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 13b7b55205..66279569f3 100644 --- a/packages/test-support/llm-mock-server/package.json +++ b/packages/test-support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index de87ab8353..56153cf056 100644 --- a/packages/test-support/llm-replay/package.json +++ b/packages/test-support/llm-replay/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 1c79647f5c..7bde44e8d1 100644 --- a/packages/test-support/loader-smoke/package.json +++ b/packages/test-support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index cd0c39aad6..1c543e7d7e 100644 --- a/packages/test-support/session-snapshot/package.json +++ b/packages/test-support/session-snapshot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-snapshot", "description": "Session-log snapshot core with an ACP protocol adapter, expected-output normalization, and fixture invariants", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index a5ffb42878..12fa142763 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 4ff26d9cb3..99faa2d453 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 5a77a4e4da..7789f7c5c4 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index dcab658721..303fece98c 100644 --- a/packages/typert/protocol/package.json +++ b/packages/typert/protocol/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-protocol", "description": "Compiler-independent Remote metadata and Typert provider protocols", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 0ba8c0ef78..354938683c 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 71a3730a24..b37b9f42ec 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index e8c4d3108d..adfedbba8c 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Stateless branded primitive types for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index 99f93075af..69f8b4077c 100644 --- a/packages/util/crypto/package.json +++ b/packages/util/crypto/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-crypto", "description": "Zero-dependency browser-safe UUID and byte-encoding helpers", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json index 53237374e2..db596ca69b 100644 --- a/packages/util/deque/package.json +++ b/packages/util/deque/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-deque", "description": "Zero-dependency circular deque with amortized constant-time end operations and bounded vacant storage", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index 863f54c5fa..13064c8ec4 100644 --- a/packages/util/home-paths/package.json +++ b/packages/util/home-paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-home-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index ced5ca67c4..e572c1f029 100644 --- a/packages/util/launch-environment/package.json +++ b/packages/util/launch-environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-launch-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 0359f5b21e..307d842840 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Host-native command and path-opening utilities with shell-free execution, cancellation, desktop detection, and WSL handoff", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index fc97c68978..89bf06b3e9 100644 --- a/packages/util/output-retention/package.json +++ b/packages/util/output-retention/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-output-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 1a75d46c5a..413e86bd6b 100644 --- a/packages/util/time/package.json +++ b/packages/util/time/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-time", "description": "Zero-dependency time vocabulary shared by wire boundaries: canonicalClientTimeZone (IANA zone validation and canonicalization only, no formatting)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index fe8a05aa6f..da889b60fb 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/values/package.json b/packages/util/values/package.json index 4f5a0e7497..e819fa7ff7 100644 --- a/packages/util/values/package.json +++ b/packages/util/values/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-values", "description": "Duplicate-install-safe value primitives for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index dfba2de7fb..a2122f9078 100644 --- a/packages/util/workspace-path/package.json +++ b/packages/util/workspace-path/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-util-workspace-path", "description": "Browser-safe Workspace path and display helpers", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 6689dbc553..bf7c8775d6 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index ac827505e1..5a1d992226 100644 --- a/packages/web/web-fetch-http/package.json +++ b/packages/web/web-fetch-http/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-fetch-http", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 70a6b2559e..31bfc344e2 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 604d0e89f8..3b35725157 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 9e273bdf65..131bd84498 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index faa6d421a9..e6b506e2f1 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index 42e2b50afa..8df8673a1c 100644 --- a/packages/webhook/webhook-github/package.json +++ b/packages/webhook/webhook-github/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook-github", "description": "Signed GitHub HTTP webhook adapter for the DeepSeek Harness webhook runtime", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index 242ba27f2e..e65b012e52 100644 --- a/packages/webhook/webhook/package.json +++ b/packages/webhook/webhook/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-webhook", "description": "Fire-and-forget webhook rule runtime that creates Workspace-backed DeepSeek Harness Sessions", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 051fd07de1..79d5867a84 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index a7354b805e..95e5bb47ac 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflowEngine", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 7ad89934b2..6ec0dcabc5 100644 --- a/packages/workflow/workflow-worker-thread/package.json +++ b/packages/workflow/workflow-worker-thread/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow-worker-thread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index eff283a88f..f3eda09122 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflowEngine service, run vocabulary, and workflow/* events", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 677e92d4b5..bc49019b02 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspaceRegistry): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.1.2-alpha.4", + "version": "0.1.2-alpha.5", "publishConfig": { "access": "public" }, From 9ea2329084c87d5f3731bdd6c532877f773ada96 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 2 Sep 2026 16:07:02 +0800 Subject: [PATCH 28/43] fix(issue-management): grant policy Project read access --- ...ject-local-issue-planning-fields.i18n.yaml | 4 +-- ...-02-project-local-issue-planning-fields.md | 4 ++- ...-project-local-issue-planning-fields.zh.md | 4 ++- .github/issue-management/policy.mjs | 9 ++++++- .github/issue-management/policy.test.mjs | 27 ++++++++++++++----- .github/workflows/issue-policy.yml | 10 +++++++ scripts/ci-workflow.spec.ts | 27 +++++++++++++++++++ 7 files changed, 74 insertions(+), 11 deletions(-) diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml index f2a98cb8a4..4b67e3ea81 100644 --- a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.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/process/2026-09-02-project-local-issue-planning-fields.md -2026-09-02-project-local-issue-planning-fields.md: 97689de4e64d60e40ba479b89f20dff1f557ec21 -2026-09-02-project-local-issue-planning-fields.zh.md: 4d815af1789324f82394d4d26b638c50f2031ec4 +2026-09-02-project-local-issue-planning-fields.md: efd0383c9701f9f4caf797d9bf1544b225e5f71c +2026-09-02-project-local-issue-planning-fields.zh.md: 3c22a506743621ac3204342575fac90d81ee8a37 diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md index 97689de4e6..efd0383c97 100644 --- a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md @@ -16,13 +16,15 @@ The `DSH Issue Management` Project owns `Priority`, `Severity`, `Cost`, `Start D Repository policy resolves `Priority` and `Start Date` from the configured Project. It rejects an Issue-backed field or the wrong data type, reads Priority from the Project item, and writes Start Date through `updateProjectV2ItemFieldValue`. Organization Issue fields are retained only as `Legacy ...` migration sources and are not read by repository workflows. +The pull-request policy workflow uses the repository `GITHUB_TOKEN` for repository Issue and pull-request reads, and a GitHub App token restricted to organization Projects read access for ProjectV2 queries. Lifecycle mutations continue to use the write-capable App token. + The Issue lifecycle workflow initializes `Start Date` only for `pull_request.opened`. It reads the pull request's live body, retains every same-repository reference that resolves to an Issue, converts `created_at` to a calendar date in the configured Project time zone, ensures the Issue is a Project item, and writes the date only when the current Project value is empty. The [organization-field implementation](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md) records the superseded cross-Project ownership decision and its event-timing rationale. Event-directed Status transitions remain owned by [the lifecycle decision](2026-08-10-event-directed-pr-review-status.md). ## Verification -[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) require Project custom fields for Priority and Start Date, cover the Shanghai date boundary, opened-only dispatch, empty-value writes, existing-value preservation, and missing Project items, and pin `updateProjectV2ItemFieldValue`. Removing an organization field requires comparing every legacy value with its Project value, including archived Project items. +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) require Project custom fields for Priority and Start Date, prove repository and Project reads use separate credentials, cover the Shanghai date boundary, opened-only dispatch, empty-value writes, existing-value preservation, and missing Project items, and pin `updateProjectV2ItemFieldValue`. Workflow tests pin the Project token's read-only permission. Removing an organization field requires comparing every legacy value with its Project value, including archived Project items. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md index 4d815af178..3c22a50674 100644 --- a/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md @@ -16,13 +16,15 @@ Priority、影响面、解决代价和日期用于在 `DSH Issue Management` 中 仓库策略从配置的 Project 解析 `Priority` 和 `Start Date`。策略拒绝 Issue 字段投影或错误的数据类型,从 Project item 读取 Priority,并通过 `updateProjectV2ItemFieldValue` 写入 Start Date。组织 Issue 字段仅作为带有 `Legacy ...` 前缀的迁移源保留,仓库工作流不会读取它们。 +PR 策略工作流使用仓库 `GITHUB_TOKEN` 读取仓库 Issue 和 PR,并使用仅有组织 Projects 读取权限的 GitHub App token 执行 ProjectV2 查询。生命周期 mutation 继续使用有写权限的 App token。 + Issue 生命周期工作流仅在 `pull_request.opened` 时初始化 `Start Date`。工作流读取 PR 的实时正文,保留每个能解析为 Issue 的同仓库引用,把 `created_at` 按配置的 Project 时区转换为日历日期,确保 Issue 是 Project item,并仅在当前 Project 值为空时写入日期。 [组织字段实现](../../archived/process/2026-08-31-pr-opened-issue-start-dates.md)记录了已被取代的跨 Project 所有权决策及其事件时机依据。由事件直接指定的 Status 转换仍由[生命周期决策](2026-08-10-event-directed-pr-review-status.zh.md)负责。 ## 验证 -[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)要求 Priority 和 Start Date 使用 Project custom field,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。 +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)要求 Priority 和 Start Date 使用 Project custom field,证明仓库读取与 Project 读取使用不同凭据,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。工作流测试固定 Project token 的只读权限。删除组织字段前必须逐项比较所有旧字段值与 Project 值,包括已归档的 Project item。 ## 考虑过的替代方案 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index b8c078510a..6b731f1318 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -418,6 +418,10 @@ function token() { return value } +function projectToken() { + return process.env.PROJECT_TOKEN || token() +} + async function api(path, options = {}) { const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, { ...options, @@ -442,7 +446,10 @@ async function graphql(query, variables) { const result = await api('/graphql', { method: 'POST', body: JSON.stringify({ query, variables }), - headers: { 'Content-Type': 'application/json' }, + headers: { + Authorization: `Bearer ${projectToken()}`, + 'Content-Type': 'application/json', + }, }) if (result.errors?.length) throw new Error(result.errors.map((error) => error.message).join('; ')) return result.data diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 6f5bdbc8ba..e2d01a5c78 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -34,7 +34,13 @@ const projectGraphqlData = ({ title: 'DSH Issue Management', fields: { nodes: [ - { id: 'status-field-id', name: 'Status', dataType: 'SINGLE_SELECT', options: [] }, + { + id: 'status-field-id', + name: 'Status', + dataType: 'SINGLE_SELECT', + isIssueField: false, + options: [], + }, ...(priorityField ? [ { @@ -285,16 +291,25 @@ test('initializes every referenced Issue only for a PR opened event', async () = }) test('reads Priority and Status from Project custom fields', async (t) => { - const previousToken = process.env.GH_TOKEN - process.env.GH_TOKEN = 'test-token' + const previousGhToken = process.env.GH_TOKEN + const previousGithubToken = process.env.GITHUB_TOKEN + const previousProjectToken = process.env.PROJECT_TOKEN + delete process.env.GH_TOKEN + process.env.GITHUB_TOKEN = 'repository-token' + process.env.PROJECT_TOKEN = 'project-token' t.after(() => { - if (previousToken === undefined) delete process.env.GH_TOKEN - else process.env.GH_TOKEN = previousToken + if (previousGhToken === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = previousGhToken + if (previousGithubToken === undefined) delete process.env.GITHUB_TOKEN + else process.env.GITHUB_TOKEN = previousGithubToken + if (previousProjectToken === undefined) delete process.env.PROJECT_TOKEN + else process.env.PROJECT_TOKEN = previousProjectToken }) const urls = [] t.mock.method(globalThis, 'fetch', async (url, options) => { urls.push(url) if (url.endsWith('/issues/42')) { + assert.equal(options.headers.Authorization, 'Bearer repository-token') return Response.json({ node_id: 'issue-id', title: 'Project metadata', @@ -307,7 +322,7 @@ test('reads Priority and Status from Project custom fields', async (t) => { }) } assert.equal(url, 'https://api.github.com/graphql') - assert.equal(options.headers.Authorization, 'Bearer test-token') + assert.equal(options.headers.Authorization, 'Bearer project-token') return Response.json({ data: projectGraphqlData({ priority: 'P1' }) }) }) diff --git a/.github/workflows/issue-policy.yml b/.github/workflows/issue-policy.yml index dde9462c33..c00f3eb71b 100644 --- a/.github/workflows/issue-policy.yml +++ b/.github/workflows/issue-policy.yml @@ -21,7 +21,17 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false + - name: Create Project read token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 + with: + client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }} + private-key: ${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }} + owner: deepseek-harness + repositories: deepseek-harness + permission-organization-projects: read - name: Validate pull request env: GITHUB_TOKEN: ${{ github.token }} + PROJECT_TOKEN: ${{ steps.app-token.outputs.token }} run: node .github/issue-management/policy.mjs pr diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 111372b79d..6e30fc5db9 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -683,6 +683,33 @@ describe('Issue lifecycle workflow', () => { const policyPullRequest = workflowEvent(policy, 'pull_request') expect(policyPullRequest.types).toContain('ready_for_review') }) + + it('uses a read-only Project token for pull request policy metadata', () => { + const policy = loadWorkflow('.github/workflows/issue-policy.yml') + const policyJob = workflowJob(policy, 'policy') + if (!Array.isArray(policyJob.steps)) throw new TypeError('Issue policy job must define steps') + const steps = policyJob.steps.filter(isRecord) + const tokenStep = steps.find(step => step.name === 'Create Project read token') + const validateStep = steps.find(step => step.name === 'Validate pull request') + + expect(tokenStep).toMatchObject({ + id: 'app-token', + uses: 'actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1', + with: { + 'client-id': '${{ vars.DSH_ISSUE_APP_CLIENT_ID }}', + 'private-key': '${{ secrets.DSH_ISSUE_APP_PRIVATE_KEY }}', + owner: 'deepseek-harness', + repositories: 'deepseek-harness', + 'permission-organization-projects': 'read', + }, + }) + expect(validateStep).toMatchObject({ + env: { + GITHUB_TOKEN: '${{ github.token }}', + PROJECT_TOKEN: '${{ steps.app-token.outputs.token }}', + }, + }) + }) }) describe('npm release workflows', () => { From a6ed19b54131b3e2995f8503d960c9df8509553c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 16:05:39 +0800 Subject: [PATCH 29/43] fix(llm): expand model listing discovery --- ...-provider-endpoint-interrogation.i18n.yaml | 4 +- ...4-draft-provider-endpoint-interrogation.md | 14 ++- ...raft-provider-endpoint-interrogation.zh.md | 14 ++- apps/web/tests/models-settings.e2e.ts | 5 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 +- .../client/ui-settings-models/README.zh.md | 4 +- .../src/client/ModelListEditor.tsx | 7 +- .../tests/provider-form.client.spec.tsx | 24 ++-- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/README.zh.md | 2 +- packages/llm/llm-pi-ai/src/discovery.ts | 117 +++++++++++++----- .../llm/llm-pi-ai/tests/discovery.spec.ts | 99 ++++++++++++++- 14 files changed, 231 insertions(+), 73 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index 277670d745..2dd955f0e3 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.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-04-draft-provider-endpoint-interrogation.md -2026-08-04-draft-provider-endpoint-interrogation.md: d4112d813ad4f5781b74639209d13952e459f7dd -2026-08-04-draft-provider-endpoint-interrogation.zh.md: 1626a34cb3163949d70688cefeec77d328c62caa +2026-08-04-draft-provider-endpoint-interrogation.md: 9f304ed1fb37bf66d452632b54fa83c0fef44104 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: b689f76739cc7ebf1e36783de5af4b7130a1bf33 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index d4112d813a..9f304ed1fb 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -6,7 +6,7 @@ English | [中文](2026-08-04-draft-provider-endpoint-interrogation.zh.md) ## Problem -Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding an OpenAI-compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and most of these endpoints do publish that list at `GET /models`. +Once a pi-ai route became [a declaration rather than a catalog lookup](2026-08-03-pi-ai-declared-provider-catalog.md), a person adding a compatible gateway had to know its model ids before they could configure it. The adapter no longer constrains them to an installed catalog, which is the point, but it also means nothing tells the user what the endpoint actually serves — and OpenAI- and Anthropic-compatible endpoints publish that list through protocol-specific model-listing routes. The obvious answer, a dynamic runtime catalog refreshed in the background, was rejected with the layer below it: it makes a route's model list external mutable state needing a cache, an invalidation story, and an offline path, while the product need is narrower. What is needed is a *question asked once*, whose answer the user adopts into `settings.yaml` — so `settings.yaml` remains the only thing deciding what a route serves. @@ -21,7 +21,7 @@ Interrogation is keyed by **settings namespace**, not by provider route: - `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. - `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. -`dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; the fixed JSON accept header, a typed-or-stored bearer credential, and Harness attribution then win case-insensitive collisions in that order. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs. +`dsh-llm-pi-ai` implements `GET {baseURL}/models` with bearer authentication for `openai-completions` and `openai-responses`. `anthropic-messages` uses the native `GET /v1/models` route with `x-api-key` and `anthropic-version: 2023-06-01`; a `baseURL` ending in `/v1` keeps that path instead of duplicating it. The parser reads the standard `data` array shared by both APIs and the enriched `models` object exposed by some compatible gateways; a present `data` array takes precedence. Array entries use their `id`, while object entries use the property key as the request id because a nested `id` may name a shared canonical model; the nested value is only an empty-key fallback. `name`, `display_name`, or `displayName` supplies the display name, with the request id as fallback. `contextWindow`, `context_window`, `context_length`, Anthropic's `max_input_tokens`, or `limit.context` supplies the context window, while `maxOutputTokens`, `max_output_tokens`, `maxTokens`, `max_tokens`, or `limit.output` supplies the output-token cap. Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; fixed protocol headers, a typed-or-stored protocol credential, and Harness attribution then win their case-insensitive collisions. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting guessed response fields as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage pattern for its own caller-supplied URLs. ### Why not pi-ai's own refresh machinery @@ -35,16 +35,18 @@ pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a ` **Have the host read the entire stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. The draft remains authoritative for the endpoint and protocol. The narrow Host-side exceptions are the stored credential, which is write-only, and profile headers, which remain deployment configuration rather than Models-page fields. -**Interrogate every pi-ai protocol.** Anthropic's listing happens to share OpenAI's envelope, and Google's does not. Supporting the ones that are easy would make coverage arbitrary and, worse, make a wrong guess at a response shape indistinguishable from a provider with no models. A protocol that says it cannot be interrogated sends the user to hand-entry, which is the documented fallback. +**Parse every pi-ai protocol as an OpenAI listing.** Anthropic shares the `data` envelope but has a documented native route and authentication headers, so it is supported explicitly rather than by guessing. Google's field set and Azure's request contract differ, while Codex uses OAuth; treating any of them as OpenAI would make a wrong guess indistinguishable from a provider with no models. An unsupported protocol sends the user to hand-entry, which remains the documented fallback. + +**Send an OpenAI SDK `User-Agent` only for model discovery.** That makes one gateway return the standard array, but it misattributes Harness traffic and makes discovery depend on an undocumented client-name branch. Reading both known OpenAI-compatible reply formats preserves Harness attribution and keeps the compatibility rule in response parsing. **Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed. ## Consequences -A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. An already-configured enterprise gateway uses the same deployment headers for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber. +A person adding a gateway can ask it what it serves instead of hunting through its documentation, and the answer arrives as candidates they choose from rather than as configuration written behind their back. When an endpoint discloses richer metadata, adopting a candidate fills its id, name, context window, and output-token cap into the editable Web row. Search preserves hidden selections, selecting all adds the visible results, and deselecting all clears every result so a filtered picker cannot submit hidden models accidentally. An already-configured enterprise gateway uses the same deployment headers and Harness `User-Agent` for interrogation and model requests without adding a header injection field to the browser protocol. The seam gained a registry that is deliberately small: one offer per namespace, no storage, no lifecycle beyond the fiber. -What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage is protocol-shaped rather than provider-shaped — an Anthropic-compatible gateway must be filled in by hand even though its listing would parse. And because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately. +What it costs: the wire gained a third secret-carrying payload, so the configuration plane's write-only surface is now three methods rather than two. Discovery coverage remains protocol-shaped rather than provider-shaped, and an endpoint using an unsupported request contract must be filled in by hand. Because nothing re-runs the question, a model list is still only as current as its last edit; that is the same trade the layer below made deliberately. ## Testing -`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — a listing with and without disclosed capacities, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` boots settings and credentials through the Loader and proves settings-only headers reach `GET /models` with request-owned headers winning collisions. `packages/llm/llm-pi-ai/tests/adapter.spec.ts` rejects profile headers Fetch cannot represent, and `packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` proves a settings write reports that configuration error while its last good routes keep serving. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while `packages/client/ui-settings-models/tests/provider-form.client.spec.tsx` verifies that the draft reaches the Remote whole, absent fields stay absent, and no settings namespace or credential is written before selection. +`packages/llm/llm/tests/topology.spec.ts` covers the registry: one offer per namespace, disposal with the fiber, normalization that drops duplicate and unusable ids without inventing capacities, the `NO_DISCOVERY`/`INVALID_DISCOVERY` refusals, and the `model-discovery-failed` Remote mapping. `packages/llm/llm-pi-ai/tests/discovery.spec.ts` drives the probe against local HTTP servers — standard arrays and enriched objects with every accepted metadata spelling, Anthropic's native path, headers, and capacity fields, route keys that differ from nested canonical ids, name fallback, a preserved deployment path, an absent credential, a configured route supplying its stored credential and headers while a typed key wins without resolving the stored one, a catalog route answering without resolving one at all, dropped rows, 401/403 versus a server fault, a non-listing and a non-JSON body, an unreachable endpoint, caller cancellation, an unsupported protocol, and the size ceiling in both its declared-length and streamed forms. `packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` boots settings and credentials through the Loader and proves settings-only headers reach `GET /models` with request-owned headers winning collisions. `packages/llm/llm-pi-ai/tests/adapter.spec.ts` rejects profile headers Fetch cannot represent, and `packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` proves a settings write reports that configuration error while its last good routes keep serving. `packages/client/connection/tests/node-half.host.spec.ts` pins the `llm/discoverModels` `/api` carrier registration, while the component and built-Web settings tests verify that the complete draft reaches the Remote, absent fields stay absent, selected metadata fills all four editable model fields, tuned rows win over rediscovery, filtered deselection clears hidden candidates, and no settings namespace or credential is written before selection. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index 1626a34cb3..b689f76739 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.zh.md)之后,要接入一个 OpenAI 兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而这类端点大多在 `GET /models` 上公布了这份列表。 +当 pi-ai 路由变成[一份声明而非 catalog 查表](2026-08-03-pi-ai-declared-provider-catalog.zh.md)之后,要接入一个兼容网关的人,必须先知道它的模型 id 才能完成配置。适配器不再把人限制在已安装 catalog 里——这正是那次改动的目的——但也意味着没有任何东西告诉用户该端点究竟服务什么,而 OpenAI 与 Anthropic 兼容端点会通过各自协议的模型列表路由公布这份信息。 显而易见的答案——后台刷新的运行时动态 catalog——已随下层一并被拒绝:它会把路由的模型列表变成需要缓存、失效语义与离线路径的外部可变状态,而产品需求要窄得多。真正需要的是*一次性询问*,其答案由用户采纳进 `settings.yaml`,从而让 `settings.yaml` 始终是决定路由服务内容的唯一真源。 @@ -21,7 +21,7 @@ Status: implemented - `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。 - `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 -`dsh-llm-pi-ai` 的实现只是一次朴素的 `GET {baseURL}/models`,且仅限 OpenAI 兼容协议。它们的列表形状是网关、自建服务与官方端点三方一致认可的那一种,而这正是该动作存在的场景。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定的 JSON accept header、键入或已存的 bearer 凭据以及 Harness attribution 随后依次以大小写不敏感方式赢得冲突。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应形状报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式形状一致。 +`dsh-llm-pi-ai` 对 `openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`。`anthropic-messages` 使用带 `x-api-key` 与 `anthropic-version: 2023-06-01` 的原生 `GET /v1/models` 路由;若 `baseURL` 已经以 `/v1` 结尾,就会保留而非重复追加该路径。解析器既读取两种 API 共用的标准 `data` 数组,也读取部分兼容网关提供的富信息 `models` 对象;若存在 `data` 数组则以它为准。数组条目使用自身的 `id`,对象条目则使用属性键作为请求 id,因为嵌套 `id` 可能点名共享的规范模型;只有属性键为空时才回退到嵌套值。显示名称取自 `name`、`display_name` 或 `displayName`,缺失时回退到请求 id。上下文窗口取自 `contextWindow`、`context_window`、`context_length`、Anthropic 的 `max_input_tokens` 或 `limit.context`,最大输出 token 数则取自 `maxOutputTokens`、`max_output_tokens`、`maxTokens`、`max_tokens` 或 `limit.output`。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定协议 headers、键入或已存的协议凭据以及 Harness attribution 随后分别以大小写不敏感方式赢得相应冲突。Azure 虽有 OpenAI 血统却不在支持范围内——它用 `api-key` header 鉴权并要求 `api-version` 查询参数——Codex 则使用 OAuth;询问二者都会把鉴权失败误报成提供方没有模型。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应字段报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式模式一致。 ### 为什么不用 pi-ai 自己的 refresh 机制 @@ -35,16 +35,18 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 **让 Host 读取整个已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有机密跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。草稿仍是端点和协议的权威来源。Host 侧的狭窄例外是只写的已存凭据,以及仍属部署配置、而非 Models 页面字段的 profile headers。 -**询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「该提供方没有模型」无法区分。一个明说自己无法被询问的协议,会把用户送去手工填写——那正是既定的回退路径。 +**把 pi-ai 每种协议都当作 OpenAI 列表解析。** Anthropic 虽共用 `data` 信封,却有文档明确规定的原生路由与鉴权 headers,因此这里显式支持它而非靠猜测。Google 的字段集合与 Azure 的请求约定不同,Codex 则使用 OAuth;把任一个当作 OpenAI 都会让猜错的响应与「提供方没有模型」无法区分。不支持的协议会把用户送去手工填写,这仍是既定回退路径。 + +**只为模型发现发送 OpenAI SDK 的 `User-Agent`。** 这能让某个网关返回标准数组,却会错误标注 Harness 流量,并让发现依赖未文档化的客户端名称分支。同时读取两种已知的 OpenAI 兼容响应格式,可以保留 Harness attribution,并把兼容规则留在响应解析中。 **用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。 ## Consequences -接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。已配置的企业网关会为询问与模型请求使用同一组部署 headers,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。 +接入网关的人可以直接问它服务什么,而不必去翻它的文档;答案以候选形式抵达,由用户自己挑选,而不是被背着写进配置。当端点公布了更丰富的元数据时,采纳候选会把 id、名称、上下文窗口与最大输出 token 数填进 Web 的可编辑行。搜索会保留隐藏项的勾选状态,全选会加入可见结果,而取消全选会清空全部结果,因此筛选后的选择器不会意外提交隐藏模型。已配置的企业网关会为询问与模型请求使用同一组部署 headers 和 Harness `User-Agent`,而无需给浏览器协议增加 header 注入字段。seam 因此多了一个刻意保持很小的注册表:每个 namespace 一份、不存储、生命周期不超出 fiber。 -代价是:协议层多了第三个承载机密的载荷,配置面的只写接口从两个方法变成三个。发现覆盖范围按协议而非按提供方划分——一个 Anthropic 兼容网关即便其列表能被解析,也仍须手工填写。而且由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。 +代价是:协议层多了第三个承载机密的载荷,配置面的只写接口从两个方法变成三个。发现覆盖范围仍按协议而非按提供方划分,使用不受支持请求约定的端点仍须手工填写。由于没有任何环节会重跑该询问,模型列表的新鲜度依旧只到最近一次编辑为止;这与下层刻意做出的取舍是同一个。 ## Testing -`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——含与不含公布容量的列表、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 `GET /models`,且请求所持有的 headers 赢得冲突。`packages/llm/llm-pi-ai/tests/adapter.spec.ts` 拒绝 Fetch 无法表示的 profile headers,`packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels` 的 `/api` 承载注册,`packages/client/ui-settings-models/tests/provider-form.client.spec.tsx` 则验证草稿完整抵达 Remote、缺席字段保持缺席,以及选择前没有 settings namespace 或凭据被写入。 +`packages/llm/llm/tests/topology.spec.ts` 覆盖注册表:每个 namespace 一份、随 fiber dispose(资源释放)、丢弃重复与不可用 id 且不凭空补容量的归一化、`NO_DISCOVERY`/`INVALID_DISCOVERY` 两种拒绝,以及 `model-discovery-failed` Remote 映射。`packages/llm/llm-pi-ai/tests/discovery.spec.ts` 针对本地 HTTP 服务器驱动探测——包括采用每种受支持元数据拼写的标准数组与富信息对象、Anthropic 原生路径、headers 与容量字段、不同于嵌套规范 id 的路由键、名称回退、被保留的部署路径、无凭据、已配置路由提供自己的已存凭据与 headers 且键入的密钥无需解析已存凭据便可压过它、catalog 路由完全不解析凭据即作答、被丢弃的行、401/403 与服务器故障之别、非列表与非 JSON 响应、不可达端点、调用方取消、不支持的协议,以及尺寸上限的「声明长度」与「流式」两种形态。`packages/llm/llm-pi-ai/tests/loader-composition.spec.ts` 通过 Loader 启动 settings 与 credentials,并证明仅配置在 settings 中的 headers 会抵达 `GET /models`,且请求所持有的 headers 赢得冲突。`packages/llm/llm-pi-ai/tests/adapter.spec.ts` 拒绝 Fetch 无法表示的 profile headers,`packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts` 证明 settings 写入会报告该配置错误,同时上一组可用路由仍继续服务。`packages/client/connection/tests/node-half.host.spec.ts` 固定 `llm/discoverModels` 的 `/api` 承载注册,而设置页的组件测试和构建后 Web 测试则验证完整草稿抵达 Remote、缺席字段保持缺席、所选元数据填满四个可编辑模型字段、用户调整过的行优先于重新发现结果、筛选后的取消选择会清除隐藏候选项,以及选择前没有 settings namespace 或凭据被写入。 diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 974184ec4b..eeda4e5042 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -179,7 +179,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('filters the discovered model catalog and preserves hidden selections', async () => { + it('filters the discovered model catalog and clears hidden selections', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-picker')) const settingsDialog = page.getByRole('dialog', { name: '设置' }) await settingsDialog.getByRole('button', { name: '编辑 minimax-cn' }).click() @@ -206,8 +206,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await search.fill('') await expect.poll(async () => boxes.count()).toBe(count) const restored = await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked)) - expect(restored.filter(Boolean)).toHaveLength(count - 1) - expect(restored.filter(checked => !checked)).toHaveLength(1) + expect(restored).toEqual(Array.from({ length: count }, () => false)) await picker.getByRole('button', { name: '全选' }).waitFor() await picker.getByRole('button', { name: '全选' }).click() const snapshot = await captureStableAria( diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 4c9a0539d4..5bf49a7f19 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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-settings-models/README.md -README.md: b3f07e1a788b223f0f49e37b9a4a54662acf6445 -README.zh.md: c23dcf95f6b9e9d47b390c3243bf3d8bb0ef26f1 +README.md: 6710646098402ec32752277c4ef244cf193fa09c +README.zh.md: 456cbc1ed7b1a85fe3ae85967e22c8b4c549ec50 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index b3f07e1a78..6710646098 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -37,7 +37,7 @@ The collapsed 自定义设置 fold carries the curated extras: `baseURL` for bot ### Adding and deleting providers -The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Search matches model ids and optional display names without clearing hidden selections, while **Select all** and **Deselect all** affect only the visible results. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. +The add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. **Add a custom provider** declares a route pi-ai does not ship; the create card asks for a unique **Provider ID**, an endpoint, a protocol, and at least one uniquely-identified model, because nothing can default those. **Fetch available models** asks the `llm/discoverModels` Remote about the endpoint the form shows, so adding a provider is one pass instead of save-then-return; the reply opens a searchable picker rather than being written, and nothing is written until **Add selected**. Each selected candidate copies its id, display name, context window, and output-token cap into the editable row when disclosed, while an existing row retains its user-tuned values. Search matches model ids and optional display names without clearing hidden selections. **Select all** adds the visible results, while **Deselect all** clears the entire selection so hidden results cannot be adopted accidentally. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its confirmation dialog names the provider. ### First-run dialogs @@ -105,7 +105,7 @@ These limits define the editor's field coverage and the page's reach; they are c - **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. -- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. +- **Interrogation covers OpenAI-compatible endpoints** — within those protocols the adapter accepts a standard `data` array or an enriched `models` map; a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index c23dcf95f6..456cbc1ed7 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -37,7 +37,7 @@ kind: "package-reference" ### 新增与删除提供方 -「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态;**全选**与**取消全选**只影响可见结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 +「新增」流程是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。**添加自定义提供方**声明一条 pi-ai 不提供的路由;创建卡片会索要唯一的 **Provider ID**、端点、协议与至少一个可唯一识别的模型,因为没有东西能为它们兜底。**获取可用模型**通过 `llm/discoverModels` Remote 查询表单显示的端点,因此新增提供方一次即可完成,而非先保存再返回;回复打开的是可搜索选择器而非直接写入,只有点击**添加所选**才会写入。每个选中候选会在提供方公布相应信息时,把 id、显示名、上下文窗口与最大输出 token 数复制进可编辑行;已经存在的行保留用户调整过的值。搜索会匹配模型 id 与可选显示名称,且不会清除隐藏项的勾选状态。**全选**会加入可见结果,而**取消全选**会清空全部勾选,以免意外采用隐藏结果。只有用户层单独携带某行时,该行才可删除(删除会恢复组合基线),其确认对话框会指名该提供方。 ### 首次运行弹窗 @@ -105,7 +105,7 @@ kind: "package-reference" - **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 -- **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 +- **询问只覆盖 OpenAI 兼容端点**:在这些协议下,适配器接受标准 `data` 数组或富信息 `models` 对象;讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 6b9dd26224..603e21b40b 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -297,12 +297,11 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { const toggleVisibleCandidates = (): void => { setPicked((current) => { - const next = new Set(current) if (visibleCandidates.every(candidate => current.has(candidate.id))) { - for (const candidate of visibleCandidates) next.delete(candidate.id) - } else { - for (const candidate of visibleCandidates) next.add(candidate.id) + return new Set() } + const next = new Set(current) + for (const candidate of visibleCandidates) next.add(candidate.id) return next }) } diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index e8bbc74d62..1fb842477e 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -529,7 +529,8 @@ describe('endpoint interrogation', () => { it('adopts only the picked candidates, keeping a row the user already tuned', async () => { const discover = vi.fn(() => Promise.resolve(ok([ - { id: 'kept', contextWindow: 999 }, { id: 'fresh', contextWindow: 4096, name: 'Fresh' }, + { id: 'kept', contextWindow: 999 }, + { id: 'fresh', contextWindow: 4096, maxTokens: 2048, name: 'Fresh' }, ]))) const { mutate } = await mountSection({ discover, @@ -544,11 +545,17 @@ describe('endpoint interrogation', () => { expect(boxes.map(box => box.checked)).toEqual([false, true]) fireEvent.click(screen.getByText(en.fetchAdopt)) + expect(screen.getByLabelText(`${en.modelId} 2`).value).toBe('fresh') + expect(screen.getByLabelText(`${en.modelName} 2`).value).toBe('Fresh') + expandModel(2) + expect(screen.getByLabelText(`${en.modelContextWindow} 2`).value).toBe('4096') + expect(screen.getByLabelText(`${en.modelMaxTokens} 2`).value).toBe('2048') + fireEvent.click(screen.getByText(en.apply)) await waitFor(() => { expect(mutate).toHaveBeenCalled() }) expect(firstMutate(mutate).ops[0]?.value).toEqual([ { id: 'kept', contextWindow: 111 }, - { id: 'fresh', contextWindow: 4096, name: 'Fresh' }, + { id: 'fresh', contextWindow: 4096, maxTokens: 2048, name: 'Fresh' }, ]) }) @@ -661,7 +668,7 @@ describe('endpoint interrogation', () => { expect(firstMutate(mutate).ops[0]?.value).toEqual([{ id: 'a' }, { id: 'b', maxTokens: 2048 }]) }) - it('filters by model id or name and scopes bulk selection to visible candidates', async () => { + it('filters by model id or name, selects visible candidates, and clears every selection', async () => { const discover = vi.fn(() => Promise.resolve(ok([ { id: 'alpha' }, { id: 'opaque-id', name: 'Beta Display' }, { id: 'gamma' }, ]))) @@ -687,14 +694,17 @@ describe('endpoint interrogation', () => { expect([...dialog.querySelectorAll('input[type="checkbox"]')] .map(box => box.checked)).toEqual([false]) - // Clearing the filter restores every row and preserves hidden selections. + // Deselecting a filtered result must also clear hidden selections so they + // cannot be adopted accidentally. fireEvent.change(search, { target: { value: '' } }) const boxes = [...dialog.querySelectorAll('input[type="checkbox"]')] - expect(boxes.map(box => box.checked)).toEqual([true, false, true]) + expect(boxes.map(box => box.checked)).toEqual([false, false, false]) + // Selecting while filtered adds only visible candidates. + fireEvent.change(search, { target: { value: 'alpha' } }) fireEvent.click(within_(dialog, en.fetchSelectAll)) - expect(boxes.map(box => box.checked)).toEqual([true, true, true]) - expect(within_(dialog, en.fetchDeselectAll)).toBeTruthy() + fireEvent.change(search, { target: { value: '' } }) + expect(boxes.map(box => box.checked)).toEqual([true, false, false]) fireEvent.change(search, { target: { value: 'missing' } }) expect(screen.getByText(en.fetchNoMatches)).toBeTruthy() diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 803c42af07..6bd2735f5a 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: 5994a72f28b0a52890cb7bf7a5bc2ee33eedf418 -README.zh.md: 5f1c893128714caf24941943c57eaf3ab43314e0 +README.md: d1d70a99e80d14150e6858105adf624ee54170bc +README.zh.md: d324f29ab2558807588a61b217358192ad0719e9 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 5994a72f28..d1d70a99e8 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -106,7 +106,7 @@ Profiles are re-read once per operation through the optional settings seam: the ### Discover models from endpoints -The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call; only a route the catalog does not describe is interrogated over the wire (`openai-completions` and `openai-responses` shapes). A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `settings.yaml` or Cordis config reach `GET /models` without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `settings.yaml` remains the only thing that decides what a route serves. +The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models` semantics with `x-api-key` and `anthropic-version`; a base URL already ending in `/v1` is not extended twice. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `settings.yaml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `settings.yaml` remains the only thing that decides what a route serves. ### Failures and recovery diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 5f1c893128..d324f29ab2 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -106,7 +106,7 @@ profile 通过可选 settings seam 每次操作重新读取:base 与用户的 ### 从端点发现模型 -插件会回答"该提供方可以提供哪些模型?",供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求;只有目录未描述的路由才会经网络询问(`openai-completions` 与 `openai-responses` 形状)。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达 `GET /models`,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。 +插件会回答"该提供方可以提供哪些模型?",供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models` 语义;已经以 `/v1` 结尾的 base URL 不会再次追加该路径。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。 ### 失败与恢复 diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index e8e353e25e..cce139a68c 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -13,11 +13,11 @@ * metadata the surface offers for adoption. `settings.yaml` remains the only * thing that decides what a route serves. * - * Only OpenAI-compatible protocols are interrogated. Their listing is the one - * shape a gateway, a self-hosted server, and the official endpoints all agree - * on, which is the case this action exists for; every other protocol reports - * that it cannot be interrogated so the surface falls back to hand-entry - * rather than guessing a response shape. + * OpenAI-compatible and Anthropic Messages protocols are interrogated through + * their native model-listing endpoints. The parser accepts the standard + * `data` array and the enriched `models` map some compatible gateways expose. + * Every other protocol reports that it cannot be interrogated so the surface + * falls back to hand-entry rather than guessing its response fields. * * @module dsh-llm-pi-ai/discovery */ @@ -28,18 +28,23 @@ import { attributionHeaders } from '@deepseek-ai/dsh-llm' import { catalogModels } from './catalog.ts' /** - * Protocols whose model listing this module can read: the two that speak - * OpenAI's `GET /models` shape with bearer auth. Azure is absent despite its - * OpenAI lineage — it authenticates with an `api-key` header and requires an - * `api-version` query — and Codex authenticates through OAuth; guessing at - * either would report an authentication failure as a provider with no models. - * pi-ai's remaining protocols are absent for the same reason. + * Protocols whose model listing this module can read. OpenAI protocols use + * bearer auth at `GET {baseURL}/models`; Anthropic Messages uses `x-api-key` + * and `anthropic-version` at its native `GET /v1/models`. Azure is absent + * despite its OpenAI lineage — it authenticates with an `api-key` header and + * requires an `api-version` query — and Codex authenticates through OAuth; + * guessing at either would report an authentication failure as a provider + * with no models. pi-ai's remaining protocols are absent for the same reason. */ const LISTABLE_PROTOCOLS: ReadonlySet = new Set([ + 'anthropic-messages', 'openai-completions', 'openai-responses', ]) +/** Stable API version required by Anthropic's model-listing endpoint. */ +const ANTHROPIC_VERSION = '2023-06-01' + /** * Endpoint replies larger than this are refused. The endpoint is whatever URL * the user typed, so the ceiling holds on the bytes actually read rather than @@ -49,16 +54,28 @@ const LISTABLE_PROTOCOLS: ReadonlySet = new Set([ */ const MAX_RESPONSE_BYTES = 4 * 1024 * 1024 -/** One entry of an OpenAI-compatible `GET /models` reply. */ +/** Capacity fields nested by enriched model-directory replies. */ +interface ListingLimit { + context?: unknown + output?: unknown +} + +/** One entry of a supported `GET /models` reply. */ interface ListingEntry { id?: unknown /** Common gateway extensions; absent from the official listings. */ name?: unknown display_name?: unknown + displayName?: unknown + contextWindow?: unknown context_window?: unknown context_length?: unknown + max_input_tokens?: unknown + maxOutputTokens?: unknown max_tokens?: unknown max_output_tokens?: unknown + maxTokens?: unknown + limit?: ListingLimit | null } /** A positive integer field of a listing entry, or `undefined` when absent or unusable. */ @@ -83,8 +100,10 @@ function label(...candidates: readonly unknown[]): string | undefined { * `https://gateway.example/openai/v1` keeps its segments instead of losing * them to `URL` resolution. */ -function listingUrl(baseURL: string): string { - return `${baseURL.replace(/\/+$/, '')}/models` +function listingUrl(baseURL: string, api: string): string { + const base = baseURL.replace(/\/+$/, '') + if (api !== 'anthropic-messages' || base.endsWith('/v1')) return `${base}/models` + return `${base}/v1/models` } /** @@ -131,29 +150,60 @@ async function readBounded(response: Response, url: string): Promise { } /** - * Read one OpenAI-compatible listing reply. Entries without a usable id are - * skipped rather than failing the whole interrogation: a single malformed row - * should not deny the user the rest of a working endpoint's catalog. + * Read one OpenAI-compatible listing reply. The standard `data` array takes + * precedence when both supported formats are present. An enriched `models` + * map uses each property key as the endpoint-facing id; its nested `id` is + * only a fallback for an empty key because gateways may put a canonical model + * identity there instead of the alias they accept on requests. + * + * Entries without a usable id are skipped rather than failing the whole + * interrogation: a single malformed row should not deny the user the rest of + * a working endpoint's catalog. Missing names fall back to the adopted id so + * the Web form receives a complete human-readable row. */ function readListing(body: unknown): LlmDiscoveredModel[] { - const data = (body as { data?: unknown } | null)?.data - if (!Array.isArray(data)) { - throw new LlmError( - 'the endpoint\'s model listing has no "data" array; enter this provider\'s models by hand', - 'DISCOVERY_FAILED', - ) + const listing = body as { data?: unknown; models?: unknown } | null + const data = listing?.data + let listed: { readonly key?: string; readonly raw: unknown }[] + if (Array.isArray(data)) { + const rows = data as readonly unknown[] + listed = rows.map(raw => ({ raw })) + } else { + const models = listing?.models + if (models === null || typeof models !== 'object' || Array.isArray(models)) { + throw new LlmError( + 'the endpoint\'s model listing has neither a "data" array nor a "models" object; ' + + 'enter this provider\'s models by hand', + 'DISCOVERY_FAILED', + ) + } + listed = Object.entries(models as Record) + .filter(([, raw]) => raw !== null && typeof raw === 'object' && !Array.isArray(raw)) + .map(([key, raw]) => ({ key, raw })) } const models: LlmDiscoveredModel[] = [] - for (const raw of data) { + for (const { key, raw } of listed) { const entry = raw as ListingEntry | null - const id = label(entry?.id) + const id = label(key, entry?.id) if (id === undefined) continue - const name = label(entry?.name, entry?.display_name) - const contextWindow = capacity(entry?.context_window, entry?.context_length) - const maxTokens = capacity(entry?.max_output_tokens, entry?.max_tokens) + const name = label(entry?.name, entry?.display_name, entry?.displayName) ?? id + const contextWindow = capacity( + entry?.contextWindow, + entry?.context_window, + entry?.context_length, + entry?.max_input_tokens, + entry?.limit?.context, + ) + const maxTokens = capacity( + entry?.maxOutputTokens, + entry?.max_output_tokens, + entry?.maxTokens, + entry?.max_tokens, + entry?.limit?.output, + ) models.push({ id, - ...name === undefined ? {} : { name }, + name, ...contextWindow === undefined ? {} : { contextWindow }, ...maxTokens === undefined ? {} : { maxTokens }, }) @@ -235,7 +285,7 @@ export async function discoverModels( 'DISCOVERY_UNSUPPORTED', ) } - const url = listingUrl(request.baseURL) + const url = listingUrl(request.baseURL, api) // A key typed into the form wins: it may replace the stored key that is // failing. The stored profile is asked past the catalog and protocol checks, // and its credential resolver remains lazy so a typed key cannot fail over a @@ -248,7 +298,12 @@ export async function discoverModels( try { const headers = new Headers(stored?.headers === undefined ? undefined : Object.entries(stored.headers)) headers.set('accept', 'application/json') - if (apiKey !== undefined) headers.set('authorization', `Bearer ${apiKey}`) + if (api === 'anthropic-messages') { + headers.set('anthropic-version', ANTHROPIC_VERSION) + if (apiKey !== undefined) headers.set('x-api-key', apiKey) + } else if (apiKey !== undefined) { + headers.set('authorization', `Bearer ${apiKey}`) + } for (const [name, value] of Object.entries(attributionHeaders())) headers.set(name, value) response = await fetch(url, { method: 'GET', diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index c504db45cb..c95dd43a31 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -111,6 +111,9 @@ describe('draft-provider model discovery', () => { body: JSON.stringify({ data: [ { id: 'acme-large', display_name: 'Acme Large', context_length: 65_536, max_output_tokens: 4096 }, + { id: 'acme-camel', displayName: 'Acme Camel', contextWindow: 131_072, maxOutputTokens: 8192 }, + { id: 'acme-mixed', name: 'Acme Mixed', context_window: 32_768, maxTokens: 2048 }, + { id: 'acme-legacy', max_tokens: 1024 }, { id: 'acme-small' }, ], }), @@ -121,13 +124,101 @@ describe('draft-provider model discovery', () => { expect(models).toEqual([ { id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }, - { id: 'acme-small' }, + { id: 'acme-camel', name: 'Acme Camel', contextWindow: 131_072, maxTokens: 8192 }, + { id: 'acme-mixed', name: 'Acme Mixed', contextWindow: 32_768, maxTokens: 2048 }, + { id: 'acme-legacy', name: 'acme-legacy', maxTokens: 1024 }, + { id: 'acme-small', name: 'acme-small' }, ]) expect(server.paths).toEqual(['/v1/models']) expect(server.headers[0]?.authorization).toBe('Bearer probe-key') expect(server.headers[0]?.['user-agent']).toBe(userAgent()) }) + it('reads an enriched models map using route ids and nested capacities', async () => { + const server = await listingServer({ + body: JSON.stringify({ + models: { + 'lobechat-deepseek-chat': { + id: 'deepseek/deepseek-v4-flash', + name: 'DeepSeek V4 Flash', + limit: { context: 1_048_576, output: 384_000 }, + }, + 'bare-route': {}, + '': { id: 'nested-id', display_name: 'Nested fallback' }, + 'malformed-route': null, + }, + }), + }) + const ctx = await harness() + + expect(await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })).toEqual([ + { + id: 'lobechat-deepseek-chat', + name: 'DeepSeek V4 Flash', + contextWindow: 1_048_576, + maxTokens: 384_000, + }, + { id: 'bare-route', name: 'bare-route' }, + { id: 'nested-id', name: 'Nested fallback' }, + ]) + }) + + it('uses Anthropic model-listing paths, headers, and capacity fields', async () => { + const server = await listingServer({ + body: JSON.stringify({ + data: [ + { + id: 'claude-sonnet', + display_name: 'Claude Sonnet', + max_input_tokens: 200_000, + max_tokens: 64_000, + }, + ], + }), + }) + const ctx = await harness() + + const rootModels = await ctx.llm.discoverModels('llm-pi-ai', { + baseURL: server.url, + api: 'anthropic-messages', + apiKey: 'anthropic-key', + }) + const versionedModels = await ctx.llm.discoverModels('llm-pi-ai', { + baseURL: `${server.url}/v1`, + api: 'anthropic-messages', + apiKey: 'anthropic-key', + }) + await ctx.llm.discoverModels('llm-pi-ai', { + baseURL: server.url, + api: 'anthropic-messages', + }) + + expect(rootModels).toEqual([ + { id: 'claude-sonnet', name: 'Claude Sonnet', contextWindow: 200_000, maxTokens: 64_000 }, + ]) + expect(versionedModels).toEqual(rootModels) + expect(server.paths).toEqual(['/v1/models', '/v1/models', '/v1/models']) + expect(server.headers.map(headers => headers['x-api-key'])) + .toEqual(['anthropic-key', 'anthropic-key', undefined]) + expect(server.headers.map(headers => headers['anthropic-version'])) + .toEqual(['2023-06-01', '2023-06-01', '2023-06-01']) + expect(server.headers.map(headers => headers.authorization)).toEqual([undefined, undefined, undefined]) + expect(server.headers.map(headers => headers['user-agent'])).toEqual([userAgent(), userAgent(), userAgent()]) + }) + + it('prefers the standard data array when both supported formats are present', async () => { + const server = await listingServer({ + body: JSON.stringify({ + data: [{ id: 'standard' }], + models: { enriched: { name: 'Enriched' } }, + }), + }) + const ctx = await harness() + + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })) + .resolves.toEqual([{ id: 'standard', name: 'standard' }]) + }) + it('keeps a deployment path instead of resolving it away', async () => { const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'm' }] }) }) const ctx = await harness() @@ -220,7 +311,7 @@ describe('draft-provider model discovery', () => { const ctx = await harness() expect(await ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })) - .toEqual([{ id: 'good' }, { id: 'zero-capacity' }]) + .toEqual([{ id: 'good', name: 'good' }, { id: 'zero-capacity', name: 'zero-capacity' }]) }) it('points at the credential for a rejected one, and only then', async () => { @@ -244,7 +335,7 @@ describe('draft-provider model discovery', () => { const ctx = await harness() await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url })) - .rejects.toThrow(/no "data" array; enter this provider's models by hand/) + .rejects.toThrow(/neither a "data" array nor a "models" object/) const broken = await listingServer({ body: 'not json at all' }) await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: broken.url })) @@ -274,7 +365,7 @@ describe('draft-provider model discovery', () => { .rejects.toMatchObject({ code: 'DISCOVERY_FAILED' }) }) - it.each(['anthropic-messages', 'azure-openai-responses', 'openai-codex-responses', 'google-generative-ai'])( + it.each(['azure-openai-responses', 'openai-codex-responses', 'google-generative-ai'])( 'says it cannot interrogate %s rather than guessing a shape', async (api) => { // Azure authenticates with an `api-key` header and an `api-version` From bfdebf1d222240b847fe9a9a7d8d0f8cc20068a4 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 16:56:06 +0800 Subject: [PATCH 30/43] test(llm): expect discovery name fallback --- packages/llm/llm-pi-ai/tests/loader-composition.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index 6ca0a61118..ecbeb254b4 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -151,7 +151,7 @@ describe('llm-pi-ai real dormant composition', () => { provider: 'acme-gateway', baseURL: server.url, api: 'openai-completions', - })).resolves.toEqual([{ id: 'acme-private' }]) + })).resolves.toEqual([{ id: 'acme-private', name: 'acme-private' }]) expect(server.paths).toEqual(['/models']) expect(server.headers[0]?.['x-company-code']).toBe('private-tenant') expect(server.headers[0]?.authorization).toBe('Bearer key-from-store') From 2f091ee8e6330f422d9a4a024c3062d4d3c73114 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 2 Sep 2026 17:58:36 +0800 Subject: [PATCH 31/43] docs(record-browser-gif): publish PR GIFs with gh --attach Prefer gh v2.99.0's repeatable --attach flag for publishing demonstration GIFs: it uploads the artifact and rewrites the body's local-path reference in place, keeping media out of git history without an orphan assets branch. The assets-branch workflow remains the fallback when the GIF exceeds 10 MB, gh is older than v2.99.0, or the repository is not on github.com. --- ...08-08-browser-gif-evidence-chain.i18n.yaml | 4 +-- .../2026-08-08-browser-gif-evidence-chain.md | 8 ++--- ...026-08-08-browser-gif-evidence-chain.zh.md | 8 ++--- .agents/skills/record-browser-gif/SKILL.md | 35 +++++++++++++++---- 4 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml index 0a540855bb..f97b5ff3cc 100644 --- a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.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/process/2026-08-08-browser-gif-evidence-chain.md -2026-08-08-browser-gif-evidence-chain.md: b1ee54aa21949c50d69be3384a35547b752e6858 -2026-08-08-browser-gif-evidence-chain.zh.md: ab60c01409a4c7cb28ca4e1980ad9d28d6634e59 +2026-08-08-browser-gif-evidence-chain.md: ec54e0c39339b7522c4939bc050cbd216ab2134e +2026-08-08-browser-gif-evidence-chain.zh.md: 3012fc5df0937a0586dfb9788fb606c45e3d6ca1 diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md index b1ee54aa21..ec54e0c393 100644 --- a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md @@ -8,7 +8,7 @@ English | [中文](2026-08-08-browser-gif-evidence-chain.zh.md) A browser-demo storyboard can contain individually truthful screenshots without proving one truthful execution. Reusing global application state can admit old settings or sessions, capture automation can accidentally combine frames from separate model runs, and a chat transcript can show a successful fallback without exposing the tool rejection that caused it. Fuzzy accessible-name matching can also accept prompt echoes or descendant text instead of the intended result. -Headless production recording has two further boundaries. A product default may open a native operating-system surface that automation cannot drive, while replacing that surface with a mock or test hook would mean the GIF no longer shows the production path. After publication, a successful git push does not prove that a private-repository GIF is fetchable or that GitHub recognizes the pull-request Markdown as an image. +Headless production recording has two further boundaries. A product default may open a native operating-system surface that automation cannot drive, while replacing that surface with a mock or test hook would mean the GIF no longer shows the production path. After publication, a successful upload or push does not prove that the uploaded asset is reachable and intact on the review path, or that GitHub recognizes the pull-request Markdown as an image. ## Decision @@ -18,7 +18,7 @@ Browser automation waits for unique, exact semantic states. When the claim conce The available browser-control workflow remains preferred. When it is unavailable, the recorder uses the repository-declared Playwright dependency in an isolated headless browser rather than installing another driver or opening the user's browser. A native production surface may be replaced only through normal application configuration with an official browser-operable production backend, and that override is stated next to the GIF. Fixtures, mock transports, synthetic events, and test-only hooks do not substantiate a real-production claim. -Publication verifies the boundary again. The assets branch contains media only, the staged and published bytes match the verified artifact, and a private-repository asset is checked through authenticated API or raw requests for its path, byte size, checksum, response status, and media type. This proves the repository-member review path only; the [documentation-site image decision](2026-08-06-doc-site-carries-its-images.md) owns why a public site cannot depend on a private raw URL. Immediately before the pull-request body changes, the live head must still equal the recorded head. After the edit, the live head is checked again and must remain at that recorded value; GitHub's Markdown renderer separately must produce the expected image. +Publication verifies the boundary again. The preferred path attaches with `gh --attach` (v2.99.0 or later; github.com only; at most 10 MB): the body file references the local GIF path, the command uploads the verified artifact and rewrites that reference in place, and the live body must then show the rewritten uploaded URL, which must respond with `200` and `image/gif`. When attach cannot apply — the GIF exceeds 10 MB, `gh` is older, or the repository is GitHub Enterprise Server — the assets-branch path remains: the branch contains media only, the staged and published bytes match the verified artifact, and a private-repository asset is checked through authenticated API or raw requests for its path, byte size, checksum, response status, and media type, proving only the repository-member review path (the [documentation-site image decision](2026-08-06-doc-site-carries-its-images.md) owns why a public site cannot depend on a private raw URL). Immediately before the pull-request body changes, the live head must still equal the recorded head. After the edit, the live head is checked again and must remain at that recorded value; GitHub's Markdown renderer separately must produce the expected image. ## Alternatives considered @@ -28,10 +28,10 @@ Publication verifies the boundary again. The assets branch contains media only, **Replace inaccessible native UI with a fixture or test hook.** That makes automation easier by changing the product path under observation. Selecting an official production backend through normal configuration keeps the exercised implementation real and makes the narrower mode explicit. -**Trust a successful assets-branch push or an anonymous fetch.** A push proves only that git accepted bytes, while private repositories intentionally reject unauthenticated raw requests. Authenticated byte verification plus GitHub Markdown rendering tests the two publication boundaries that reviewers use. +**Trust a successful upload or push.** An upload or push proves only that GitHub accepted the bytes, not that the body reference points at the uploaded asset or that the Markdown renders the image. Re-reading the live body and rendering it through GitHub's Markdown API test the two publication boundaries reviewers use. ## Consequences GUI evidence now establishes one causal execution rather than a collage of plausible states, and reviewers can inspect both a structured tool failure and the completed result. Publication detects stale pull-request heads, corrupted or misplaced media, and invalid image Markdown before the body is treated as finished. -The workflow spends additional scratch state, may repeat a real model round after a capture failure, and usually adds a detail frame plus authenticated publication checks. Headless recordings can use fewer production backends than an interactive desktop, and every selected backend is stated next to the GIF. +The workflow spends additional scratch state, may repeat a real model round after a capture failure, and usually adds a detail frame plus publication checks — live head and body re-reads, Markdown rendering, and an uploaded-URL fetch; the assets-branch path adds its authenticated asset checks. Headless recordings can use fewer production backends than an interactive desktop, and every selected backend is stated next to the GIF. diff --git a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md index ab60c01409..3012fc5df0 100644 --- a/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-browser-gif-evidence-chain.zh.md @@ -8,7 +8,7 @@ Status: implemented 浏览器演示的分镜可以由每张都真实的截图组成,却无法证明这些截图来自同一次真实执行。复用应用全局状态可能引入旧设置或旧会话;录制自动化可能误将不同模型运行的画面合并;聊天 transcript(文本记录)可能显示降级处理成功,却没有揭示触发降级的工具拒绝。按无障碍名称进行模糊匹配,还可能误把提示词回显或后代文本当成预期结果。 -无头模式下的生产环境录制还有两道边界。产品默认配置可能打开自动化无法操控的原生操作系统界面,而用 mock 或测试钩子替换该界面,就意味着 GIF 不再展示生产路径。发布之后,git 推送成功也不能证明私有仓库中的 GIF 可以获取,或 GitHub 能将 PR(Pull Request)的 Markdown 识别为图片。 +无头模式下的生产环境录制还有两道边界。产品默认配置可能打开自动化无法操控的原生操作系统界面,而用 mock 或测试钩子替换该界面,就意味着 GIF 不再展示生产路径。发布之后,上传或推送成功也不能证明上传的资产在评审路径上可达且完好,或 GitHub 能将 PR(Pull Request)的 Markdown 识别为图片。 ## 决策 @@ -18,7 +18,7 @@ Status: implemented 仍应优先使用已有的浏览器控制工作流。如果该工作流不可用,录制程序应在隔离的无头浏览器中使用仓库已声明的 Playwright 依赖,而不是安装其他驱动或打开用户的浏览器。只有通过正常应用配置选用官方且可由浏览器操作的生产后端,才能替换原生生产界面,并且必须在 GIF 旁注明这一覆盖。fixture(测试前置数据)、mock 传输层、合成事件和测试专用钩子均不能支撑真实生产实现的主张。 -发布环节会再次验证边界。资产分支只包含媒体文件,暂存和发布的字节必须与已验证产物一致;对于私有仓库中的资产,应通过经身份验证的 API 或原始内容请求,检查其路径、字节大小、校验和、响应状态和媒体类型。这只能证明仓库成员的评审访问路径;[文档站点图片决策](2026-08-06-doc-site-carries-its-images.zh.md)解释了公共站点为何不能依赖私有的原始内容 URL。修改 PR 正文之前,必须再次确认在线 head 仍与录制时的 head 相同。编辑后还要再次检查在线 head,且它必须保持为该记录值;GitHub 的 Markdown 渲染器则须单独生成预期图片。 +发布环节会再次验证边界。首选路径使用 `gh --attach` 上传(v2.99.0 或更高版本;仅限 github.com;不超过 10 MB):正文文件引用本地 GIF 路径,命令上传已验证的产物并把该引用就地改写,之后在线正文必须显示改写后的上传 URL,且该 URL 必须返回 `200` 与 `image/gif`。当 attach 无法使用时——GIF 超过 10 MB、`gh` 版本过旧或仓库位于 GitHub Enterprise Server——仍保留资产分支路径:该分支只包含媒体文件,暂存和发布的字节必须与已验证产物一致,私有仓库中的资产须通过经身份验证的 API 或原始内容请求检查其路径、字节大小、校验和、响应状态和媒体类型,这只能证明仓库成员的评审访问路径([文档站点图片决策](2026-08-06-doc-site-carries-its-images.zh.md)解释了公共站点为何不能依赖私有的原始内容 URL)。修改 PR 正文之前,必须再次确认在线 head 仍与录制时的 head 相同。编辑后还要再次检查在线 head,且它必须保持为该记录值;GitHub 的 Markdown 渲染器则须单独生成预期图片。 ## 曾考虑的替代方案 @@ -28,10 +28,10 @@ Status: implemented **使用 fixture 或测试钩子替换无法访问的原生 UI。**这种做法通过改变被观察的产品路径来简化自动化。通过正常配置选用官方生产后端,既能保持受测实现真实,也能明确表述所采用的较窄运行模式。 -**相信资产分支推送成功,或依赖匿名请求。**推送只能证明 git 接受了相应字节,而私有仓库会有意拒绝未经身份验证的原始内容请求。经身份验证的字节校验与 GitHub Markdown 渲染验证,覆盖了评审者实际使用的两道发布边界。 +**相信上传成功,或相信推送成功。**上传或推送只能证明 GitHub 接受了相应字节,不能证明正文引用指向已上传的资产,或 Markdown 能把图片渲染出来。重新读取在线正文并经 GitHub 的 Markdown API 渲染,覆盖了评审者实际使用的两道发布边界。 ## 后果 GUI 证据现在能证明一次具有因果关系的执行,而不会把不同执行中的可信画面当作同一次执行的证据;评审者既可以检查结构化的工具失败,也可以检查最终完成的结果。在 PR 正文被视为完成之前,发布验证可以发现陈旧的 PR head、损坏或位置错误的媒体文件,以及无效的图片 Markdown。 -该工作流会占用额外的临时状态;录制失败后,可能需要再运行一次由真实模型驱动的场景;通常还会增加一张详情帧和经身份验证的发布检查。相比交互式桌面,无头录制可使用的生产后端更少;每个所选后端都会在 GIF 旁说明。 +该工作流会占用额外的临时状态;录制失败后,可能需要再运行一次由真实模型驱动的场景;通常还会增加一张详情帧以及发布检查——在线 head 与正文的复读、Markdown 渲染和上传 URL 请求;资产分支路径还会增加其经身份验证的资产检查。相比交互式桌面,无头录制可使用的生产后端更少;每个所选后端都会在 GIF 旁说明。 diff --git a/.agents/skills/record-browser-gif/SKILL.md b/.agents/skills/record-browser-gif/SKILL.md index 3566dfb763..93a55769c0 100644 --- a/.agents/skills/record-browser-gif/SKILL.md +++ b/.agents/skills/record-browser-gif/SKILL.md @@ -1,24 +1,24 @@ --- name: record-browser-gif -description: Record browser or Web UI interaction demos as optimized GIFs using the available built-in browser, state-based frame capture, and deterministic encoding, then publish to a dedicated assets branch when the task includes attaching the GIF to a pull request. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow. +description: Record browser or Web UI interaction demos as optimized GIFs using the available built-in browser, state-based frame capture, and deterministic encoding, then attach the GIF to a pull request with `gh --attach`, falling back to a dedicated assets branch where attach cannot apply. Use when asked to make, record, or generate a GIF that demonstrates a browser workflow, and for every pull request that changes product-user-visible GUI behavior, which MUST include a GIF recorded from the pull request's real server and model flow. --- # Record Browser GIF -Produce a short, truthful UI demonstration as a local GIF, and — only when the task includes attaching it to a pull request — publish it through the assets-branch workflow at the end of this skill. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size. +Produce a short, truthful UI demonstration as a local GIF, and — only when the task includes attaching it to a pull request — publish it through the attach workflow at the end of this skill. Use the browser-control skill for interaction and the bundled encoder for repeatable timing, dimensions, and size. The [evidence-chain decision](../../notes/implemented/process/2026-08-08-browser-gif-evidence-chain.md) owns why one storyboard comes from one isolated run and why publication revalidates both the artifact and the demonstrated pull-request head. ## Every GUI pull request includes a GIF -A pull request that changes product-user-visible GUI behavior MUST include a demonstration GIF recorded with this skill and embedded in the pull request body via [the assets-branch workflow](#publish-to-an-assets-branch). +A pull request that changes product-user-visible GUI behavior MUST include a demonstration GIF recorded with this skill and embedded in the pull request body via [the attach workflow](#publish-the-gif). The recording itself is part of the evidence: use a real server booted from that pull request's branch tree, a real API key, and real model rounds. Never substitute fixture queries, mock transports, synthetic event injection, or test-only hooks unless the user explicitly asked for a fixture recording. Next to the embed, state the exact demonstrated commit SHA, the tree and origin that served it, any mode flags or browser-state exceptions, and whether a real model round ran, so reviewers know exactly what the recording proves. ## Keep recording separate from publication - Recording produces frame images and one local `.gif` artifact only; it never mutates remote state. -- Publication — pushing the GIF to an assets branch and embedding it in a pull request body — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch. +- Publication — attaching the GIF to a pull request body with `gh --attach`, or pushing it to an assets branch and embedding its URL where attach cannot apply — is the separate final step, performed only when the task includes attaching the GIF to a pull request. It never touches the pull request's own branch. - Preserve the requested recording conditions. A real-server or real-API demo must not use fixture queries, mock transports, synthetic event injection, or test-only hooks. If credentials or the server are unavailable, report that limitation instead of substituting a fixture. - Never read or expose credential values. Use the application's normal configuration path and a benign demonstration prompt. @@ -76,11 +76,34 @@ For a large artifact, reduce `--max-width` first, then `--colors` or `--fps`; re 3. Run `git status --short` and confirm frames and the artifact landed only under ignored paths. 4. Return the absolute GIF path, render it when the client supports local media, and state whether the recording used a real API, fixture, or another transport. When the task does not include attaching the GIF to a pull request, stop here. -## Publish to an assets branch +## Publish the GIF Perform this step only when the task includes attaching the GIF to a pull request. -Never commit a GIF to the pull request's own branch or any branch that merges into a long-lived branch: binary media committed there bloats the repository history for every future clone. GIFs live on a dedicated orphan assets branch — a branch with no parent commit and nothing but media — and one assets branch serves a whole pull request series (named `-assets`; list existing ones with `git ls-remote --heads origin '*assets*'`). +Never commit a GIF to the pull request's own branch or any branch that merges into a long-lived branch: binary media committed there bloats the repository history for every future clone. Prefer `gh --attach`, which uploads the GIF to GitHub and rewrites the body reference in one command, so no branch carries the media. + +### Attach with gh + +`gh --attach` requires `gh` v2.99.0 or later (`gh --version`), a repository on github.com — GitHub Enterprise Server is not supported — write access to the repository, and a GIF at or below 10 MB. Confirm the verified artifact fits that limit; when it does not, shrink it with `--max-width`, then `--colors` or `--fps`, before attaching. + +Write the GIF into the body file as an ordinary local-path reference, using the same path passed to `--attach`; `gh` rewrites the reference in place to the uploaded URL, keeping its position and alt text: + +```markdown +![]() +``` + +Immediately before attaching, re-read the pull request's live head — for a new pull request, the pushed branch tip — and compare it with the commit recorded next to the GIF. Stop and re-record when it moved. Then attach: + +```sh +gh pr create --body-file --attach # new pull request +gh pr edit --body-file --attach # existing pull request +``` + +`--attach` is repeatable but refuses the same file twice. A GIF the body does not reference is appended at the end, where alt text set on the flag (`--attach '#'`) applies; a rewritten reference keeps the body's alt text. After attaching, re-read the live head and require it to remain at that recorded commit. Re-read the live body and confirm the reference now points at the uploaded URL, render the body through GitHub's Markdown API and confirm the expected ``, and fetch the uploaded URL once to confirm `200` and `image/gif`. + +### Fall back to an assets branch + +Use the assets-branch workflow only when `gh --attach` cannot apply: the GIF still exceeds 10 MB, `gh` is older than v2.99.0, or the repository is not on github.com. GIFs then live on a dedicated orphan assets branch — a branch with no parent commit and nothing but media — and one assets branch serves a whole pull request series (named `-assets`; list existing ones with `git ls-remote --heads origin '*assets*'`). Before either workflow below pushes, verify that the assets branch contains media only and that the staged GIF's checksum matches the verified local artifact. From ccecf4db0417069ca806d8f1e07f7986b78614fa Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 2 Sep 2026 18:00:43 +0800 Subject: [PATCH 32/43] fix(llm): address model discovery review --- ...-provider-endpoint-interrogation.i18n.yaml | 4 +- ...4-draft-provider-endpoint-interrogation.md | 6 +-- ...raft-provider-endpoint-interrogation.zh.md | 6 +-- ...specific-model-listing-discovery.i18n.yaml | 6 +++ ...otocol-specific-model-listing-discovery.md | 39 +++++++++++++++++++ ...col-specific-model-listing-discovery.zh.md | 39 +++++++++++++++++++ .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- packages/llm/llm-pi-ai/README.i18n.yaml | 4 +- packages/llm/llm-pi-ai/README.md | 3 +- packages/llm/llm-pi-ai/README.zh.md | 3 +- packages/llm/llm-pi-ai/src/catalog.ts | 6 ++- packages/llm/llm-pi-ai/src/discovery.ts | 14 +++++-- packages/llm/llm-pi-ai/src/endpoint.ts | 20 ++++++++++ packages/llm/llm-pi-ai/tests/catalog.spec.ts | 33 ++++++++++++++++ .../llm/llm-pi-ai/tests/discovery.spec.ts | 7 +++- 17 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md create mode 100644 .agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md create mode 100644 packages/llm/llm-pi-ai/src/endpoint.ts diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index 2dd955f0e3..82f9d4ae6b 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.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-04-draft-provider-endpoint-interrogation.md -2026-08-04-draft-provider-endpoint-interrogation.md: 9f304ed1fb37bf66d452632b54fa83c0fef44104 -2026-08-04-draft-provider-endpoint-interrogation.zh.md: b689f76739cc7ebf1e36783de5af4b7130a1bf33 +2026-08-04-draft-provider-endpoint-interrogation.md: a6d8d32f12711744cb1380d2ab2767dcd3dd63f6 +2026-08-04-draft-provider-endpoint-interrogation.zh.md: e162ddb30706e871a2a42b5bbe7e09a451296289 diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md index 9f304ed1fb..a6d8d32f12 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md @@ -21,7 +21,7 @@ Interrogation is keyed by **settings namespace**, not by provider route: - `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires. - `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored or echoed back. It does ride the client's outgoing envelope like every other secret-bearing payload, where a `subscribeEnvelopes()` observer can see it; redacting that tap is a configuration-plane-wide change, not this method's to make alone. Connection authenticates the method with the complete Host API: it makes the host issue a GET to a caller-chosen URL and reports the outcome, which an anonymous caller must not receive. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered. -`dsh-llm-pi-ai` implements `GET {baseURL}/models` with bearer authentication for `openai-completions` and `openai-responses`. `anthropic-messages` uses the native `GET /v1/models` route with `x-api-key` and `anthropic-version: 2023-06-01`; a `baseURL` ending in `/v1` keeps that path instead of duplicating it. The parser reads the standard `data` array shared by both APIs and the enriched `models` object exposed by some compatible gateways; a present `data` array takes precedence. Array entries use their `id`, while object entries use the property key as the request id because a nested `id` may name a shared canonical model; the nested value is only an empty-key fallback. `name`, `display_name`, or `displayName` supplies the display name, with the request id as fallback. `contextWindow`, `context_window`, `context_length`, Anthropic's `max_input_tokens`, or `limit.context` supplies the context window, while `maxOutputTokens`, `max_output_tokens`, `maxTokens`, `max_tokens`, or `limit.output` supplies the output-token cap. Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; fixed protocol headers, a typed-or-stored protocol credential, and Harness attribution then win their case-insensitive collisions. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting guessed response fields as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage pattern for its own caller-supplied URLs. +`dsh-llm-pi-ai` applies the protocol-specific listing routes, authentication, URL normalization, response formats, and metadata rules recorded by [protocol-specific model listing discovery](2026-09-02-protocol-specific-model-listing-discovery.md). Profile resolution rejects names and values Fetch cannot represent, so a malformed deployment header is reported as a configuration error before interrogation. Configured profile headers are installed first; fixed protocol headers, a typed-or-stored protocol credential, and Harness attribution then win their case-insensitive collisions. A protocol without a documented listing contract answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting guessed response fields as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so deployment path segments remain intact. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage pattern for its own caller-supplied URLs. ### Why not pi-ai's own refresh machinery @@ -35,9 +35,7 @@ pi-ai supplies `createProvider({ fetchModels })` plus `Models.refresh()` and a ` **Have the host read the entire stored profile instead of accepting a draft.** No secret would cross the wire for an already-configured provider. But adding a provider would then require saving an unusable configuration first, and a form whose endpoint was edited but not yet saved would silently interrogate the old one. The draft remains authoritative for the endpoint and protocol. The narrow Host-side exceptions are the stored credential, which is write-only, and profile headers, which remain deployment configuration rather than Models-page fields. -**Parse every pi-ai protocol as an OpenAI listing.** Anthropic shares the `data` envelope but has a documented native route and authentication headers, so it is supported explicitly rather than by guessing. Google's field set and Azure's request contract differ, while Codex uses OAuth; treating any of them as OpenAI would make a wrong guess indistinguishable from a provider with no models. An unsupported protocol sends the user to hand-entry, which remains the documented fallback. - -**Send an OpenAI SDK `User-Agent` only for model discovery.** That makes one gateway return the standard array, but it misattributes Harness traffic and makes discovery depend on an undocumented client-name branch. Reading both known OpenAI-compatible reply formats preserves Harness attribution and keeps the compatibility rule in response parsing. +**Interrogate every pi-ai protocol.** Coverage based on convenient response similarities would be arbitrary and would make a wrong guess indistinguishable from a provider with no models. Anthropic is included only through its documented native listing contract, as the [protocol-specific extension](2026-09-02-protocol-specific-model-listing-discovery.md) records; Google's field set and Azure's request contract differ, while Codex uses OAuth. An unsupported protocol sends the user to hand-entry, which remains the documented fallback. **Buffer the reply with `response.text()` and check its length.** Simpler, but the bound would arrive after the bytes did, and the endpoint is whatever URL the user typed. diff --git a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md index b689f76739..e162ddb307 100644 --- a/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.zh.md @@ -21,7 +21,7 @@ Status: implemented - `LlmDiscoveredModel` 除 `id` 外每个字段都可选,因为大多数列表只公布 id。回复是候选而非 catalog:采纳其中一条的界面仍要补上适配器所需的容量。 - `llm.discoverModels` 把同一份草稿送过协议层。它的 `apiKey` 是可承载机密的第三个、也是最后一个载荷(另两个是 `settings.update`/`mutate` 与 `credentials.set`),且绝不被存储或回显。它确实会像其他承载机密的载荷一样随客户端外发信封同行,`subscribeEnvelopes()` 观察者看得到;把那个抽头脱敏是整个配置面的改动,不该由这一个方法独自决定。Connection 用与完整 Host API 相同的会话认证该方法:它让宿主向调用方选定的 URL 发起 GET 并回报结果,匿名调用者绝不能获得这类探测能力。每一种拒绝都折叠为 `model-discovery-failed`,其消息是适配器自己的文本,details 点名被询问的端点,绝不点名所提供的凭据。 -`dsh-llm-pi-ai` 对 `openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`。`anthropic-messages` 使用带 `x-api-key` 与 `anthropic-version: 2023-06-01` 的原生 `GET /v1/models` 路由;若 `baseURL` 已经以 `/v1` 结尾,就会保留而非重复追加该路径。解析器既读取两种 API 共用的标准 `data` 数组,也读取部分兼容网关提供的富信息 `models` 对象;若存在 `data` 数组则以它为准。数组条目使用自身的 `id`,对象条目则使用属性键作为请求 id,因为嵌套 `id` 可能点名共享的规范模型;只有属性键为空时才回退到嵌套值。显示名称取自 `name`、`display_name` 或 `displayName`,缺失时回退到请求 id。上下文窗口取自 `contextWindow`、`context_window`、`context_length`、Anthropic 的 `max_input_tokens` 或 `limit.context`,最大输出 token 数则取自 `maxOutputTokens`、`max_output_tokens`、`maxTokens`、`max_tokens` 或 `limit.output`。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定协议 headers、键入或已存的协议凭据以及 Harness attribution 随后分别以大小写不敏感方式赢得相应冲突。Azure 虽有 OpenAI 血统却不在支持范围内——它用 `api-key` header 鉴权并要求 `api-version` 查询参数——Codex 则使用 OAuth;询问二者都会把鉴权失败误报成提供方没有模型。其余协议一律以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应字段报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此 `https://gateway.example/openai/v1` 这类部署路径会保留其路径段。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式模式一致。 +`dsh-llm-pi-ai` 会应用[协议特定模型列表发现](2026-09-02-protocol-specific-model-listing-discovery.zh.md)记录的列表路由、认证、URL 归一化、响应格式与元数据规则。Profile 解析会拒绝 Fetch 无法表示的名称与值,因此格式错误的部署 header 会在询问前以配置错误报告。已配置的 profile headers 最先装入;固定协议 headers、键入或已存的协议凭据以及 Harness attribution 随后分别以大小写不敏感方式赢得相应冲突。没有文档化列表约定的协议会以 `DISCOVERY_UNSUPPORTED` 回答,让界面回退到手工填写,而不是把猜错的响应字段报成一个空提供方。`baseURL` 按前缀而非待解析 URL 处理,因此部署路径段会保持不变。回复在四兆字节上限下读取,且上限落在实际收到的字节上——端点是用户自己填的 URL,因此会先看声明的 `content-length` 作为善意提示,但绝不把它当作边界;这与 `dsh-web-fetch` 面对自己的调用方提供 URL 时所用的两段式模式一致。 ### 为什么不用 pi-ai 自己的 refresh 机制 @@ -35,9 +35,7 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 **让 Host 读取整个已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有机密跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。草稿仍是端点和协议的权威来源。Host 侧的狭窄例外是只写的已存凭据,以及仍属部署配置、而非 Models 页面字段的 profile headers。 -**把 pi-ai 每种协议都当作 OpenAI 列表解析。** Anthropic 虽共用 `data` 信封,却有文档明确规定的原生路由与鉴权 headers,因此这里显式支持它而非靠猜测。Google 的字段集合与 Azure 的请求约定不同,Codex 则使用 OAuth;把任一个当作 OpenAI 都会让猜错的响应与「提供方没有模型」无法区分。不支持的协议会把用户送去手工填写,这仍是既定回退路径。 - -**只为模型发现发送 OpenAI SDK 的 `User-Agent`。** 这能让某个网关返回标准数组,却会错误标注 Harness 流量,并让发现依赖未文档化的客户端名称分支。同时读取两种已知的 OpenAI 兼容响应格式,可以保留 Harness attribution,并把兼容规则留在响应解析中。 +**询问每一种 pi-ai 协议。** 根据便利的响应相似性选择覆盖范围会显得武断,也会让猜错的响应与「提供方没有模型」无法区分。Anthropic 仅通过其文档化原生列表约定纳入支持,具体由[协议特定扩展](2026-09-02-protocol-specific-model-listing-discovery.zh.md)记录;Google 的字段集合与 Azure 的请求约定不同,Codex 则使用 OAuth。不支持的协议会把用户送去手工填写,这仍是既定回退路径。 **用 `response.text()` 缓冲整个回复再判断长度。** 更简单,但上限会在字节已经到达之后才生效,而端点是用户随手填的任意 URL。 diff --git a/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.i18n.yaml new file mode 100644 index 0000000000..51508f91b9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.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-09-02-protocol-specific-model-listing-discovery.md +2026-09-02-protocol-specific-model-listing-discovery.md: a4fb9d3c0fba685727f78d97cf5948da1c184c5e +2026-09-02-protocol-specific-model-listing-discovery.zh.md: 66e4dd76432a666ea8c30c931f1db3444a7084d4 diff --git a/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md new file mode 100644 index 0000000000..a4fb9d3c0f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md @@ -0,0 +1,39 @@ +# Agent Note: Reading protocol-specific model listings + +Status: implemented + +English | [中文](2026-09-02-protocol-specific-model-listing-discovery.zh.md) + +## Problem + +The [draft provider interrogation](2026-08-04-draft-provider-endpoint-interrogation.md) originally read the OpenAI-compatible `data` array only. Some compatible gateways instead publish an enriched `models` object, while Anthropic publishes a native model-listing route with different authentication and URL rules. Treating either case as unsupported forced a user to copy model ids and capacities by hand even though the endpoint disclosed them. + +One gateway could be made to return an OpenAI-style array by sending an OpenAI SDK `User-Agent`. That behavior was undocumented, changed request attribution, and made the reply depend on a client identity rather than on a supported response parser. + +## Decision + +`dsh-llm-pi-ai` reads model listings according to the selected protocol. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer authentication. `anthropic-messages` uses `GET /v1/models?limit=1000` with `x-api-key` and `anthropic-version: 2023-06-01`. The Anthropic page size is the documented maximum; discovery does not follow `has_more`, so an endpoint advertising more than 1,000 models exposes only its first page. + +Anthropic SDK resource methods append `/v1` themselves. Discovery and inference therefore treat a configured Anthropic `baseURL` ending in `/v1` as the same API root as the address without that suffix. Deployment path prefixes remain intact: `https://gateway.example/tenant/v1` lists at `/tenant/v1/models` and sends messages to `/tenant/v1/messages`. + +The parser accepts a `data` array or an enriched `models` object, with a present array taking precedence. Array entries use their `id`; object entries use the property key because a nested `id` may name a canonical model instead of the route alias accepted on requests. Only object-valued map entries are considered models, so primitive directory metadata cannot become a candidate accidentally. A nested `id` is the fallback for an empty property key. + +The parser normalizes the supported name and capacity spellings into `LlmDiscoveredModel`. A missing display name becomes the request id so adoption fills a complete editable row. The request keeps the Harness attribution headers; response parsing, not client impersonation, provides gateway compatibility. + +## Alternatives considered + +**Follow every Anthropic page.** Cursor traversal would return listings larger than 1,000 entries, but it adds multi-request failure, cancellation, cursor-progress, and aggregate-size behavior to a configuration action. The implementation requests Anthropic's maximum page and documents the remaining truncation. + +**Send an OpenAI SDK `User-Agent` for discovery.** This made one gateway return `data`, but it misattributed Harness traffic and relied on an undocumented client-name branch. Reading both known reply formats keeps attribution accurate. + +**Adopt every property of a `models` object.** A primitive-valued property does not prove that its key is a model id and may be directory metadata such as a count or status. Restricting entries to records avoids inventing model candidates. + +## Consequences + +The Models page can interrogate OpenAI-compatible gateways and Anthropic Messages endpoints without changing request identity. Discovered candidates carry route ids, names, context windows, and output-token caps when the endpoint provides them, and name-only listings still receive an editable label through the id fallback. Anthropic addresses work in either root or `/v1` form for both discovery and inference. + +The supported formats remain an explicit compatibility set rather than arbitrary JSON inference. Anthropic accounts with more than 1,000 visible models require hand-entry for entries outside the first page, and primitive-valued `models` properties are ignored. + +## Testing + +Local HTTP-server tests pin both accepted response formats, field normalization, name fallback, ignored malformed entries, Anthropic headers and the maximum-page query. Provider tests drive Anthropic requests through pi-ai and prove that root, `/v1`, and prefixed `/v1` addresses reach exactly one versioned Messages path. diff --git a/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md new file mode 100644 index 0000000000..66e4dd7643 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md @@ -0,0 +1,39 @@ +# Agent Note: 读取协议特定的模型列表 + +Status: implemented + +[English](2026-09-02-protocol-specific-model-listing-discovery.md) | 中文 + +## 问题 + +[提供方草稿询问决策](2026-08-04-draft-provider-endpoint-interrogation.zh.md)最初只读取 OpenAI 兼容的 `data` 数组。一些兼容网关改为公布富信息 `models` 对象,而 Anthropic 公布了具有不同认证与 URL 规则的原生模型列表路由。把任一情况视为不受支持,都会迫使用户手工复制模型 id 和容量,即使端点已经公布这些信息。 + +向一个网关发送 OpenAI SDK `User-Agent` 可以使其返回 OpenAI 风格数组。该行为没有文档,改变了请求归属,并使回答取决于客户端身份而非受支持的响应解析器。 + +## 决策 + +`dsh-llm-pi-ai` 按所选协议格式读取模型列表。`openai-completions` 与 `openai-responses` 以 bearer 认证使用 `GET {baseURL}/models`。`anthropic-messages` 以 `x-api-key` 和 `anthropic-version: 2023-06-01` 使用 `GET /v1/models?limit=1000`。Anthropic 页大小采用文档规定的最大值;模型发现不会继续跟随 `has_more`,因此公布超过 1,000 个模型的端点只会暴露第一页。 + +Anthropic SDK 资源方法会自行追加 `/v1`。因此,模型发现与推理会把末尾为 `/v1` 的 Anthropic `baseURL` 视为与不带该后缀的地址相同的 API 根地址。部署路径前缀会保留:`https://gateway.example/tenant/v1` 在 `/tenant/v1/models` 列表,并向 `/tenant/v1/messages` 发送消息。 + +解析器接受 `data` 数组或富信息 `models` 对象,并在数组存在时优先使用它。数组条目使用自身的 `id`;对象条目使用属性键,因为嵌套 `id` 可能指向规范模型,而不是请求所接受的路由别名。只有值为对象的映射条目才视为模型,因此原始类型的目录元数据不会意外成为候选。空属性键才会回退到嵌套 `id`。 + +解析器会把受支持的名称与容量拼写归一化为 `LlmDiscoveredModel`。缺失的显示名会变成请求 id,使采纳操作填入完整的可编辑行。请求保留 Harness 归属标头;网关兼容性由响应解析提供,而非冒充客户端身份。 + +## 考虑过的替代方案 + +**跟随 Anthropic 的所有页面。** 游标遍历可以返回超过 1,000 个条目的列表,但会给配置操作增加多请求失败、取消、游标推进与总大小处理。实现请求 Anthropic 的最大页面,并记录剩余截断限制。 + +**为模型发现发送 OpenAI SDK `User-Agent`。** 这会让一个网关返回 `data`,但会错误标记 Harness 流量,并依赖未记录的客户端名称分支。读取两种已知响应格式可以保持归属准确。 + +**采纳 `models` 对象的每个属性。** 原始类型属性不能证明其键是模型 id,也可能是数量或状态等目录元数据。把条目限制为记录可避免虚构模型候选。 + +## 后果 + +Models 页面无需改变请求身份,即可询问 OpenAI 兼容网关与 Anthropic Messages 端点。发现的候选会在端点提供时携带路由 id、名称、上下文窗口与最大输出 token 数,只有 id 的列表也会通过 id 回退获得可编辑标签。Anthropic 地址以根地址或 `/v1` 形式配置时,模型发现与推理都能工作。 + +受支持格式仍是显式兼容集合,而不是任意 JSON 推断。可见模型超过 1,000 个的 Anthropic 账户需要手工录入第一页之外的条目,原始类型的 `models` 属性会被忽略。 + +## 测试 + +本地 HTTP 服务器测试钉住两种受支持响应格式、字段归一化、名称回退、忽略畸形条目、Anthropic 标头与最大页查询。提供方测试通过 pi-ai 驱动 Anthropic 请求,并证明根地址、`/v1` 地址和带前缀的 `/v1` 地址只到达一个带版本的 Messages 路径。 diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 5bf49a7f19..2d8accc664 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/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-settings-models/README.md -README.md: 6710646098402ec32752277c4ef244cf193fa09c -README.zh.md: 456cbc1ed7b1a85fe3ae85967e22c8b4c549ec50 +README.md: cac2785886346a0c1e81009404fdb0da238bbe40 +README.zh.md: 7df96a9a1b9474b5e88d8f9814f353f92eb1a31d diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 6710646098..cac2785886 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -105,7 +105,7 @@ These limits define the editor's field coverage and the page's reach; they are c - **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. -- **Interrogation covers OpenAI-compatible endpoints** — within those protocols the adapter accepts a standard `data` array or an enriched `models` map; a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand. +- **Interrogation covers OpenAI-compatible and Anthropic Messages endpoints** — OpenAI protocols accept a standard `data` array or an enriched `models` map, while Anthropic uses its native model-listing route; every other protocol reports that it cannot be asked and its models are entered by hand. - **Undeclared live routes render nowhere** — a route registered without a configurable-provider declaration has no settings address; it stays visible in pickers but not on this page's rows. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 456cbc1ed7..7df96a9a1b 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -105,7 +105,7 @@ kind: "package-reference" - **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 -- **询问只覆盖 OpenAI 兼容端点**:在这些协议下,适配器接受标准 `data` 数组或富信息 `models` 对象;讲其他协议的网关会报告自己无法被询问,其模型需手工填写。 +- **询问覆盖 OpenAI 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 6bd2735f5a..5041fa7a5b 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/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/llm/llm-pi-ai/README.md -README.md: d1d70a99e80d14150e6858105adf624ee54170bc -README.zh.md: d324f29ab2558807588a61b217358192ad0719e9 +README.md: a49bc393495447e8fa4323c0530de1db860f5570 +README.zh.md: 0d43672df325a8f6c0526db77119df00a8ba51d6 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index d1d70a99e8..a49bc39349 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -106,7 +106,7 @@ Profiles are re-read once per operation through the optional settings seam: the ### Discover models from endpoints -The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models` semantics with `x-api-key` and `anthropic-version`; a base URL already ending in `/v1` is not extended twice. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `settings.yaml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `settings.yaml` remains the only thing that decides what a route serves. +The plugin answers "which models can this provider serve?" for a route a configuration surface is editing or drafting. A route the installed catalog ships is answered from that catalog with no network call; only a route the catalog does not describe is interrogated over the wire. `openai-completions` and `openai-responses` use `GET {baseURL}/models` with bearer auth, while `anthropic-messages` uses native `GET /v1/models?limit=1000` semantics with `x-api-key` and `anthropic-version`; a base URL already ending in `/v1` is not extended twice for discovery or inference. A named configured route supplies its stored credential and profile `headers` inside the Host, so deployment headers configured through `settings.yaml` or Cordis config reach model discovery without becoming discovery-request or Models-page fields; a key typed into the form still wins over the stored credential. The parser accepts either the standard `data` array or an enriched `models` map, normalizing each candidate's id, display name, context window, and output-token cap; Anthropic's `max_input_tokens` and `max_tokens` feed the same capacity fields, a map key remains the request id even when its entry names a different canonical id, primitive-valued map properties are ignored, and a missing display name falls back to that request id. The reply is candidate metadata a surface may offer for adoption — nothing is stored, and `settings.yaml` remains the only thing that decides what a route serves. ### Failures and recovery @@ -212,6 +212,7 @@ These limits define where the adapter stops and future work begins. They are cur - **The layered merge has no delete for dict keys** — a `reasoningEfforts` level, `modelOverrides` entry, or `compat` field the base declares can be overridden but not removed by the user layer. - **`headers` can carry a credential the redactor never sees** — profile resolution rejects names and values Fetch cannot represent, but the dict remains plain strings; store credentials as `apiKeyEnv` references. - **A route's catalog never refreshes itself** — the catalog is whatever `settings.yaml` says; nothing here queries a provider for the models it serves. +- **Anthropic discovery reads at most 1,000 models** — the request uses the API's maximum page size but does not traverse `has_more`; entries beyond the first page must be added by hand. - **One wire protocol per route** — a mixed-protocol catalog route cannot host a model of the other protocol; splitting the provider across two route keys is the workaround. - **A modality declaration is not verified** — a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again; switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that request. - **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index d324f29ab2..0d43672df3 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -106,7 +106,7 @@ profile 通过可选 settings seam 每次操作重新读取:base 与用户的 ### 从端点发现模型 -插件会回答"该提供方可以提供哪些模型?",供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models` 语义;已经以 `/v1` 结尾的 base URL 不会再次追加该路径。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。 +插件会回答"该提供方可以提供哪些模型?",供配置界面正在编辑或起草的路由使用。已安装目录提供的路由直接由目录回答,不发网络请求;只有目录未描述的路由才会经网络询问。`openai-completions` 与 `openai-responses` 使用带 bearer 鉴权的 `GET {baseURL}/models`,`anthropic-messages` 则以 `x-api-key` 和 `anthropic-version` 使用原生 `GET /v1/models?limit=1000` 语义;已经以 `/v1` 结尾的 base URL 在模型发现或推理时都不会再次追加该路径。已配置且具名的路由会在 Host 内部提供已存凭据与 profile `headers`,因此通过 `settings.yaml` 或 Cordis 配置设置的部署标头可以到达模型发现请求,但不会成为发现请求或 Models 页面的字段;表单中新键入的密钥仍优先于已存凭据。解析器接受标准 `data` 数组或富信息 `models` 对象,并归一化每个候选的 id、显示名、上下文窗口与最大输出 token 数;Anthropic 的 `max_input_tokens` 与 `max_tokens` 会进入相同容量字段,即使对象条目点名了另一个规范 id,对象键仍是请求 id,原始类型的对象属性会被忽略,缺失的显示名则回退到该请求 id。回答是界面可以提供给用户采纳的候选元数据——不存储任何内容,`settings.yaml` 仍然是决定路由服务内容的唯一事实。 ### 失败与恢复 @@ -212,6 +212,7 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish - **分层合并对字典键没有删除**——base 声明的 `reasoningEfforts` 等级、`modelOverrides` 条目或 `compat` 字段可以被用户层覆盖,但不能被移除。 - **`headers` 可以携带 redactor 永远看不到的凭据**——profile 解析会拒绝 Fetch 无法表示的名称与值,但该字典仍是纯字符串;以 `apiKeyEnv` 引用存储凭据。 - **路由目录不会自行刷新**——目录就是 `settings.yaml` 的内容;这里没有任何机制向提供方查询它提供的模型。 +- **Anthropic 模型发现最多读取 1,000 个模型**——请求使用 API 的最大页大小,但不会遍历 `has_more`;第一页之外的条目需要手工添加。 - **每条路由一种协议格式**——混合协议目录路由无法承载另一协议格式的模型;把提供方拆到两个路由键是变通办法。 - **模态声明不受校验**——声明 `image` 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。 - **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。 diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts index a4b7d97ebe..9f0cd0e6c4 100644 --- a/packages/llm/llm-pi-ai/src/catalog.ts +++ b/packages/llm/llm-pi-ai/src/catalog.ts @@ -28,6 +28,7 @@ import type { Provider, ThinkingLevelMap, } from '@earendil-works/pi-ai' +import { anthropicApiRoot } from './endpoint.ts' /** * Pricing for a model the installed catalog does not describe. The harness @@ -855,10 +856,11 @@ export function resolveRouteModels(request: RouteCatalogRequest): RouteCatalog { invalid(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the` + ' route\'s api to the wire protocol its endpoint speaks') } - const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl - if (baseUrl === undefined) { + const configuredBaseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl + if (configuredBaseUrl === undefined) { invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`) } + const baseUrl = api === 'anthropic-messages' ? anthropicApiRoot(configuredBaseUrl) : configuredBaseUrl // Capacities fall back to the route's own defaults, so a model listing that // discloses nothing but ids still yields a serviceable route. The fallback // is a guess by construction, which is why it is a configurable route field diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index cce139a68c..fa87cc1fe2 100644 --- a/packages/llm/llm-pi-ai/src/discovery.ts +++ b/packages/llm/llm-pi-ai/src/discovery.ts @@ -26,6 +26,7 @@ import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai import type { LlmDiscoveredModel, LlmModelDiscoveryOperation } from '@deepseek-ai/dsh-llm' import { attributionHeaders } from '@deepseek-ai/dsh-llm' import { catalogModels } from './catalog.ts' +import { anthropicApiRoot } from './endpoint.ts' /** * Protocols whose model listing this module can read. OpenAI protocols use @@ -45,6 +46,9 @@ const LISTABLE_PROTOCOLS: ReadonlySet = new Set([ /** Stable API version required by Anthropic's model-listing endpoint. */ const ANTHROPIC_VERSION = '2023-06-01' +/** Largest model-list page accepted by Anthropic's public endpoint. */ +const ANTHROPIC_MODEL_LIMIT = 1000 + /** * Endpoint replies larger than this are refused. The endpoint is whatever URL * the user typed, so the ceiling holds on the bytes actually read rather than @@ -102,8 +106,8 @@ function label(...candidates: readonly unknown[]): string | undefined { */ function listingUrl(baseURL: string, api: string): string { const base = baseURL.replace(/\/+$/, '') - if (api !== 'anthropic-messages' || base.endsWith('/v1')) return `${base}/models` - return `${base}/v1/models` + if (api !== 'anthropic-messages') return `${base}/models` + return `${anthropicApiRoot(base)}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}` } /** @@ -150,11 +154,13 @@ async function readBounded(response: Response, url: string): Promise { } /** - * Read one OpenAI-compatible listing reply. The standard `data` array takes + * Read one supported model-listing reply. The standard `data` array takes * precedence when both supported formats are present. An enriched `models` * map uses each property key as the endpoint-facing id; its nested `id` is * only a fallback for an empty key because gateways may put a canonical model - * identity there instead of the alias they accept on requests. + * identity there instead of the alias they accept on requests. Only + * object-valued map entries are models; primitive properties are ignored + * because they may be directory metadata rather than model records. * * Entries without a usable id are skipped rather than failing the whole * interrogation: a single malformed row should not deny the user the rest of diff --git a/packages/llm/llm-pi-ai/src/endpoint.ts b/packages/llm/llm-pi-ai/src/endpoint.ts new file mode 100644 index 0000000000..245ae6e712 --- /dev/null +++ b/packages/llm/llm-pi-ai/src/endpoint.ts @@ -0,0 +1,20 @@ +/** + * Endpoint normalization shared by pi-ai model discovery and inference. + * + * @module dsh-llm-pi-ai/endpoint + */ + +/** + * Return the API root expected by the Anthropic SDK. + * + * Anthropic resource methods append `/v1/...` themselves. Accepting a user + * address that already ends in `/v1` therefore requires removing that suffix + * before model routing, while discovery appends its own native listing path to + * the same root. + * @param baseURL - configured Anthropic endpoint, with or without `/v1`. + * @returns the endpoint root without trailing slashes or a terminal `/v1`. + */ +export function anthropicApiRoot(baseURL: string): string { + const base = baseURL.replace(/\/+$/, '') + return base.endsWith('/v1') ? base.slice(0, -3) : base +} diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 83cee8e239..4d81ba1eaa 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -95,6 +95,39 @@ describe('hand-declared providers', () => { expect(server.headers[0]?.authorization).toBe('Bearer test-key') }) + it.each([ + ['', '/v1/messages'], + ['/v1', '/v1/messages'], + ['/tenant/v1', '/tenant/v1/messages'], + ])('routes an Anthropic base ending in %s without duplicating its API version', async (suffix, path) => { + const server = await mockServer([{ + status: 400, + body: JSON.stringify({ type: 'error', error: { type: 'invalid_request_error', message: 'stop' } }), + }]) + const ctx = await harness({ + providers: { + 'acme-anthropic': { + apiKeyEnv: KEY_ENV, + api: 'anthropic-messages', + baseURL: `${server.url}${suffix}`, + models: [{ id: 'claude-test', contextWindow: 200_000, maxTokens: 4096 }], + }, + }, + }) + + const result = await assemble(ctx, { + provider: 'acme-anthropic', + model: 'claude-test', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'hi' }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }) + + expect(result.finish.kind).toBe('error') + expect(server.paths).toEqual([path]) + }) + it('lists and resolves the declared models rather than a catalog', async () => { const server = await mockServer([]) const ctx = await harness(gateway(`${server.url}/v1`)) diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index c95dd43a31..c844a250a2 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -146,6 +146,7 @@ describe('draft-provider model discovery', () => { 'bare-route': {}, '': { id: 'nested-id', display_name: 'Nested fallback' }, 'malformed-route': null, + 'primitive-route': 'not a model record', }, }), }) @@ -197,7 +198,11 @@ describe('draft-provider model discovery', () => { { id: 'claude-sonnet', name: 'Claude Sonnet', contextWindow: 200_000, maxTokens: 64_000 }, ]) expect(versionedModels).toEqual(rootModels) - expect(server.paths).toEqual(['/v1/models', '/v1/models', '/v1/models']) + expect(server.paths).toEqual([ + '/v1/models?limit=1000', + '/v1/models?limit=1000', + '/v1/models?limit=1000', + ]) expect(server.headers.map(headers => headers['x-api-key'])) .toEqual(['anthropic-key', 'anthropic-key', undefined]) expect(server.headers.map(headers => headers['anthropic-version'])) From ef88756f13c73df88eb1f1cf82281d20a62d9853 Mon Sep 17 00:00:00 2001 From: lsdsjy <1356263+lsdsjy@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:02:10 +0800 Subject: [PATCH 33/43] feat(session, agent, web): support same-session message editing --- .agents/notes/archived/manifest.json | 3 + ...7-31-drop-user-message-edit-stub.i18n.yaml | 6 + .../2026-07-31-drop-user-message-edit-stub.md | 1 + ...26-07-31-drop-user-message-edit-stub.zh.md | 1 + ...9-human-transcript-append-origin.i18n.yaml | 4 +- ...26-07-29-human-transcript-append-origin.md | 12 +- ...07-29-human-transcript-append-origin.zh.md | 12 +- ...ranscript-log-ordered-projection.i18n.yaml | 4 +- ...0-web-transcript-log-ordered-projection.md | 4 +- ...eb-transcript-log-ordered-projection.zh.md | 4 +- ...-same-session-user-message-edit.i18n.yaml} | 6 +- ...26-09-01-same-session-user-message-edit.md | 49 ++ ...09-01-same-session-user-message-edit.zh.md | 49 ++ .../markdown-cjk-strong/ui.expected.md | 2 + .../expected/markdown-images/ui.expected.md | 2 + .../markdown-inline-code-links/ui.expected.md | 2 + .../expected/math-rendering/ui.expected.md | 2 + .../message-edit/replay.override.json | 12 + .../reference-composer/order.expected.md | 2 + .../skill-user-invoke/ui-expanded.expected.md | 2 + .../expected/skill-user-invoke/ui.expected.md | 2 + .../stats-paged-history/ui.expected.md | 2 + .../expected/steer-all/mid-steer.expected.md | 2 + apps/web/tests/message-actions.e2e.ts | 6 +- apps/web/tests/message-edit.e2e.ts | 90 +++ .../mid-stream.expected.md | 2 + apps/web/tsconfig.json | 1 + docs/architecture.i18n.yaml | 4 +- docs/architecture.md | 7 +- docs/architecture.zh.md | 7 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 4 +- docs/config-catalog.zh.md | 6 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 42 +- docs/event-producer-consumer.zh.md | 42 +- docs/persistence-catalog.i18n.yaml | 4 +- docs/persistence-catalog.md | 13 +- docs/persistence-catalog.zh.md | 13 +- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 12 +- docs/subsystems/conversation.zh.md | 12 +- docs/subsystems/core.i18n.yaml | 4 +- docs/subsystems/core.md | 39 +- docs/subsystems/core.zh.md | 39 +- docs/subsystems/session-query.i18n.yaml | 4 +- docs/subsystems/session-query.md | 6 +- docs/subsystems/session-query.zh.md | 6 +- docs/subsystems/session-title.i18n.yaml | 4 +- docs/subsystems/session-title.md | 4 +- docs/subsystems/session-title.zh.md | 4 +- docs/subsystems/session.i18n.yaml | 4 +- docs/subsystems/session.md | 68 +- docs/subsystems/session.zh.md | 68 +- docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 6 +- docs/tool-catalog.zh.md | 6 +- .../api/session-controller/README.i18n.yaml | 4 +- packages/api/session-controller/README.md | 10 +- packages/api/session-controller/README.zh.md | 10 +- .../src/client/contract/session.ts | 14 + .../src/client/contract/snapshot.ts | 18 +- .../src/client/sessions/session.ts | 60 +- .../api/session-controller/src/commands.ts | 368 ++++++++- .../api/session-controller/src/history.ts | 11 +- packages/api/session-controller/src/index.ts | 14 + packages/api/session-controller/src/types.ts | 30 + .../tests/fake-api.client.ts | 5 + .../tests/session-edit.host.spec.ts | 723 ++++++++++++++++++ .../session-history-journal.host.spec.ts | 45 ++ ...session-pending-submissions.client.spec.ts | 61 +- .../session-controller/tests/test-remote.ts | 7 + packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 12 +- packages/client/ui-chat/README.zh.md | 12 +- packages/client/ui-chat/src/client/apply.ts | 4 + .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 5 +- .../ui-chat/src/client/chat/ChatView.tsx | 80 +- .../src/client/chat/MessageIconActions.tsx | 17 +- .../src/client/chat/MessageItem.module.css | 88 +++ .../ui-chat/src/client/chat/MessageItem.tsx | 177 ++++- .../ui-chat/src/client/contract/slots.ts | 16 + .../ui-chat/src/client/contract/snapshot.ts | 3 + .../chat-snapshot-builder.ts | 19 +- .../src/client/conversation-nodes/message.ts | 3 +- packages/client/ui-chat/src/client/locale.ts | 6 + .../tests/apply-inject.client.spec.tsx | 23 + .../ui-chat/tests/chat-view.client.spec.tsx | 167 +++- .../tests/gate-branch-tails.client.spec.tsx | 1 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/contract/conversation.ts | 8 + .../src/client/conversation/assembler.ts | 32 +- .../src/client/conversation/assembly.ts | 27 +- .../src/client/conversation/presentation.ts | 85 ++ .../ui-conversation/src/client/index.ts | 2 +- .../conversation-presentation.client.spec.ts | 65 ++ .../conversation-registry.client.spec.ts | 2 + .../tests/queue-dock.client.spec.tsx | 1 + .../schedule-catalog-action.client.spec.tsx | 1 + .../tests/ui-session.client.spec.ts | 1 + .../ui-trajectory/tests/views.client.spec.tsx | 1 + .../tests/plan-review-panel.client.spec.tsx | 1 + .../user-questions-composer.client.spec.tsx | 1 + .../compaction-basic/README.i18n.yaml | 4 +- .../compaction/compaction-basic/README.md | 2 +- .../compaction/compaction-basic/README.zh.md | 2 +- .../compaction/compaction-basic/src/index.ts | 5 +- .../tests/compaction-basic.spec.ts | 55 +- packages/core/agent-loop/README.i18n.yaml | 4 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/README.zh.md | 2 +- packages/core/agent-loop/src/agent.ts | 45 +- .../agent-loop/tests/interception.spec.ts | 98 +++ packages/core/agent/README.i18n.yaml | 4 +- packages/core/agent/README.md | 8 +- packages/core/agent/README.zh.md | 8 +- packages/core/agent/src/inbox.ts | 102 ++- packages/core/agent/src/runtime-types.ts | 21 +- packages/core/agent/src/types.ts | 17 +- packages/core/agent/tests/agent.spec.ts | 73 +- packages/core/session/README.i18n.yaml | 4 +- packages/core/session/README.md | 5 +- packages/core/session/README.zh.md | 5 +- packages/core/session/package.json | 4 + packages/core/session/src/conversation.ts | 127 +++ packages/core/session/src/index.ts | 26 +- packages/core/session/src/types.ts | 17 +- .../core/session/tests/conversation.spec.ts | 120 +++ .../src/client/api-catalog.ts | 10 +- .../src/client/slot-catalog.ts | 15 +- .../extensions/tool-cordis/src/api-catalog.ts | 30 +- .../session-query-sqlite/tests/sqlite.spec.ts | 50 ++ .../session-query/README.i18n.yaml | 4 +- .../session-query/session-query/README.md | 6 +- .../session-query/session-query/README.zh.md | 6 +- .../session-query/src/documents.ts | 5 + .../session-query/src/tracing.ts | 10 +- .../session-query/session-query/src/types.ts | 2 +- .../session-query/tests/tracing.spec.ts | 32 + .../tool-session-query/README.i18n.yaml | 4 +- .../tool-session-query/README.md | 2 +- .../tool-session-query/README.zh.md | 2 +- .../tool-session-query/src/input.ts | 16 +- .../tool-session-query/src/operations.ts | 4 +- .../tests/tool-session-query.spec.ts | 4 +- .../session-persistence/tests/contract.ts | 29 +- .../session/session-title/README.i18n.yaml | 4 +- packages/session/session-title/README.md | 4 +- packages/session/session-title/README.zh.md | 4 +- packages/session/session-title/src/index.ts | 40 +- .../session-title/tests/provider.spec.ts | 98 +++ .../session-turn-outline/README.i18n.yaml | 4 +- .../session/session-turn-outline/README.md | 2 +- .../session/session-turn-outline/README.zh.md | 2 +- .../session-turn-outline/src/projection.ts | 21 +- .../tests/projection.spec.ts | 64 +- .../client-runtime/src/fixtures.ts | 1 + .../client-runtime/src/sessions.ts | 5 + .../tests/runtime.client.spec.tsx | 1 + scripts/gen-cordis-catalog.ts | 4 + scripts/type-equiv.manifest.json | 35 + .../tool-schemas.expected.json | 6 +- snapshots/web/bash-abort-row/ui.expected.md | 2 + .../web/cordis-tool-round/ui.expected.md | 2 + .../feedback-command/ack-expanded.expected.md | 2 + .../web/feedback-command/ack.expected.md | 2 + .../feedback-release/ack-expanded.expected.md | 2 + .../web/feedback-release/ack.expected.md | 2 + .../fresh-round-trip/ui-expanded.expected.md | 2 + snapshots/web/fresh-round-trip/ui.expected.md | 2 + .../reloaded-expanded.expected.md | 2 + .../web/lifecycle-chrome/reloaded.expected.md | 2 + .../cancel-expanded.expected.md | 2 + .../web/live-interactions/cancel.expected.md | 2 + .../live-interactions/error-auth.expected.md | 2 + .../web/live-interactions/loading.expected.md | 2 + .../retry-exhausted.expected.md | 2 + .../retry-expanded.expected.md | 2 + .../web/live-interactions/retry.expected.md | 2 + .../running-draft.expected.md | 2 + snapshots/web/message-actions/ui.expected.md | 2 + snapshots/web/message-edit/snapshot.yml | 9 + snapshots/web/message-edit/ui.expected.md | 44 ++ snapshots/web/minimal-preset/ui.expected.md | 2 + snapshots/web/ptc-round/ui.expected.md | 2 + .../answered-expanded.expected.md | 2 + .../question-composer/answered.expected.md | 2 + .../question-composer/cancelled.expected.md | 2 + .../web/queue-actions/collapsed.expected.md | 2 + .../web/queue-actions/editing.expected.md | 2 + .../preserved-expanded.expected.md | 2 + .../web/queue-actions/preserved.expected.md | 2 + snapshots/web/queue-actions/ui.expected.md | 2 + .../web/queued-image/delivered.expected.md | 2 + snapshots/web/queued-image/queued.expected.md | 2 + snapshots/web/skill-tool-row/ui.expected.md | 2 + snapshots/web/steering/mid-steer.expected.md | 2 + .../turn-tail-actions/completed.expected.md | 2 + .../web/turn-tail-actions/focused.expected.md | 2 + .../web/turn-tail-actions/running.expected.md | 2 + .../web/turn-tail-actions/settled.expected.md | 2 + .../usage-expanded.expected.md | 2 + snapshots/web/web-search-round/ui.expected.md | 2 + snapshots/web/workflow-run/ui.expected.md | 2 + tsconfig.base.json | 1 + tsconfig.host.json | 1 + 208 files changed, 4057 insertions(+), 348 deletions(-) create mode 100644 .agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml rename .agents/notes/{implemented => archived}/simplification/2026-07-31-drop-user-message-edit-stub.md (98%) rename .agents/notes/{implemented => archived}/simplification/2026-07-31-drop-user-message-edit-stub.zh.md (98%) rename .agents/notes/implemented/{simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml => feature/2026-09-01-same-session-user-message-edit.i18n.yaml} (56%) create mode 100644 .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md create mode 100644 .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md create mode 100644 apps/web/tests/expected/message-edit/replay.override.json create mode 100644 apps/web/tests/message-edit.e2e.ts create mode 100644 packages/api/session-controller/tests/session-edit.host.spec.ts create mode 100644 packages/client/ui-conversation/src/client/conversation/presentation.ts create mode 100644 packages/client/ui-conversation/tests/conversation-presentation.client.spec.ts create mode 100644 packages/core/session/src/conversation.ts create mode 100644 packages/core/session/tests/conversation.spec.ts create mode 100644 snapshots/web/message-edit/snapshot.yml create mode 100644 snapshots/web/message-edit/ui.expected.md diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index fa3abbea4f..3238b019df 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -469,6 +469,9 @@ "simplification/2026-07-30-sidebar-resize-without-visible-pill.i18n.yaml": "sha256:ad42430fef4a5db610f8c56acfcda79b40396418694f664a5b0fb093bff1f114", "simplification/2026-07-30-sidebar-resize-without-visible-pill.md": "sha256:6f2cfc5121371ec19c7178b31777c223e16bebdc9b63b7654a61cadc4a765b63", "simplification/2026-07-30-sidebar-resize-without-visible-pill.zh.md": "sha256:ab859eeb12c6a74da3c37d411af52fce37bed3195941570b2f23ccd9a00d55fe", + "simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml": "sha256:b1c80890f8ad1fb6a62536f2dfcd44879e66fbce4e36ca471dde6734faee8b4a", + "simplification/2026-07-31-drop-user-message-edit-stub.md": "sha256:7c3cc891454822e99b9574a9db296d890261bcb4f86077343e049c757c8f6bdb", + "simplification/2026-07-31-drop-user-message-edit-stub.zh.md": "sha256:1725034af33f8f45157a6e244ee51c7582fff62f2ea54aade570746c9f0c1ef1", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.i18n.yaml": "sha256:531c446f0e95054f8ced17be9a180f8b0a823f7e9d5ce466c94c2f9cff90a111", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md": "sha256:a35a6372aabdf7cbc211f1bd5820d85d3467c9ed50f84e05caa3339382379ce7", "simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.zh.md": "sha256:a6ed9530289a783c3d7a1ddb038fba6b7daf7feb773298a57e811791e354d438", diff --git a/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml new file mode 100644 index 0000000000..91b829274f --- /dev/null +++ b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.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/archived/simplification/2026-07-31-drop-user-message-edit-stub.md +2026-07-31-drop-user-message-edit-stub.md: 06b3f7cf1096b1a02ae0efeeec94e1a86a5a4b10 +2026-07-31-drop-user-message-edit-stub.zh.md: cad2981ed0aabd356570291a0039dfed207d0ee2 diff --git a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md rename to .agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md index 5a34be1dd2..06b3f7cf10 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.md +++ b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.md @@ -1,6 +1,7 @@ # Agent Note: Drop the user-message edit stub Status: implemented +Archived: 2026-09-01 English | [中文](2026-07-31-drop-user-message-edit-stub.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.zh.md similarity index 98% rename from .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md rename to .agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.zh.md index 288f2bda5d..cad2981ed0 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.zh.md +++ b/.agents/notes/archived/simplification/2026-07-31-drop-user-message-edit-stub.zh.md @@ -1,6 +1,7 @@ # Agent Note: 移除 user 消息的编辑存根 Status: implemented +Archived: 2026-09-01 [English](2026-07-31-drop-user-message-edit-stub.md) | 中文 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index d8a34eb622..633687f4cd 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.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/bug-fix/2026-07-29-human-transcript-append-origin.md -2026-07-29-human-transcript-append-origin.md: 00f4cf09139bdba4c0fffcdc476b698d098154bc -2026-07-29-human-transcript-append-origin.zh.md: 631ba71d841033c19a9daa21e646468a0e366966 +2026-07-29-human-transcript-append-origin.md: 8e5f35312546d140234553498c00f1dc2ca175cb +2026-07-29-human-transcript-append-origin.zh.md: f0402d0f7ad8f1afab8babfde4aadc4902724737 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index 00f4cf0913..8e5f353125 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md @@ -12,19 +12,19 @@ Nothing was lost from the log. `Session.snapshotEvents()` still returned every o ## Decision -Model and human projections are separate, and the event's own marker decides which one an event belongs to. `dsh-session` exports the marker split `isAppendSurfaceEvent(event)` and `isReplacementSurfaceEvent(event)` over the two `SurfaceOp` variants, from the browser-safe `surface` module. Append-origin events are the durable source for a transcript; replacement copies stay model-only. Everything that must send exactly what the model sees — `deriveMessages`, token accounting, the compaction backends, tool pairing, injected-context liveness, cross-session reference projection — keeps reading `session.surface`. +Model and human projections are separate. `dsh-session` exports the marker split `isAppendSurfaceEvent(event)` and `isReplacementSurfaceEvent(event)` over the two `SurfaceOp` variants, from the browser-safe `surface` module. Append-origin events are the default durable source for a transcript; replacement copies stay model-only unless a replacement `user/message` carries the explicit `conversationOp` introduced by [same-session user-message editing](../feature/2026-09-01-same-session-user-message-edit.md). That operation starts a new visible generation and hides its declared raw-event range without deleting it. Everything that must send exactly what the model sees — `deriveMessages`, token accounting, the compaction backends, tool pairing, injected-context liveness, cross-session reference projection — keeps reading `session.surface`. The terminal replays the transcript from append-origin surface events and keeps a shadowed step's tool cards paired through `transcriptToolCallIds`, which reads the append-origin `assistant/message` rather than surface membership. A landed compaction contributes one dim `… earlier context was compacted …` row at its own log position: the marker reports where the model stopped seeing that history instead of erasing it. The framed checkpoint payload never renders, and both paths classify a surface event by the same marker, so a compaction that arrives live and the same log replayed after resume produce the same transcript. Only replay re-derives `tool/call` pairing: a call event carries no marker of its own and inherits membership from the `assistant/message` that advertised it, which the live listener has necessarily just rendered. -A checkpoint is recognized through the compaction seam's own contract — `isCompactCheckpointSource`, the backend-independent marker `CompactionEngine` requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. `dsh-session-reference` already consumes that predicate to project another session's log; this is the same question asked by a different reader. Other replacements are silent: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. +A checkpoint is recognized through the compaction seam's own contract — `isCompactCheckpointSource`, the backend-independent marker `CompactionEngine` requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. `dsh-session-reference` already consumes that predicate to project another session's log; this is the same question asked by a different reader. Replacement events without `conversationOp` remain silent: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. -`session.history` counts only append-origin messages toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compaction/summary` event stays on the page of the replacement that cites it. +`session.history` counts append-origin messages and explicit conversation replacements toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compaction/summary` event stays on the page of the replacement that cites it and an edited generation can retain its hidden raw interval for lossless replay. -No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required. +Compaction still changes no user-facing replacement state. Same-session Edit adds its own persisted `conversationOp` and RPC operation without changing compaction transactions. ## Deferred -The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint and its cited source events as a unit, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but when the checkpoint was the oldest counted message, the old pagination rule happened to include the whole shadowed range on the same page. +The browser client is fixed separately, in [the web transcript projection note](2026-07-30-web-transcript-log-ordered-projection.md): it projects the default append-origin transcript in log order, applies explicit edited-generation ranges before assembly, and renders a compaction marker component. It also closes the pagination hole this change opened — because `session.history` no longer spends quota on the checkpoint, it never cuts on the checkpoint and its cited source events as a unit, so a page can carry a checkpoint citing a `surfaceOp.start` outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but when the checkpoint was the oldest counted message, the old pagination rule happened to include the whole shadowed range on the same page. The terminal's [archived live compaction progress decision](../../archived/feature/2026-07-30-compaction-progress-visibility.md) uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's `sourceEventSeqs` remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite `renderReplacement(event)` extraction. @@ -38,7 +38,7 @@ The terminal's [archived live compaction progress decision](../../archived/featu **Derive the marker from the `compaction/*` bracket instead of the checkpoint.** Rejected for the transcript: the bracket is a pair of time-point markers around an operation, while the transcript needs the position where the surface actually changed. The bracket is the right source for progress and duration, which this change does not render. -**Classify events by re-folding the log, as `session-query` does for search (`current` / `shadowed` / `log-only`).** Rejected: a fold answers a whole-log question, while a projection asks a per-event one that the event's own marker already answers in constant time. +**Classify every replacement through the model-surface fold, as `session-query` does for search (`current` / `shadowed` / `log-only`).** Rejected: compaction transcript membership remains a per-event marker question. Same-session Edit instead carries a distinct `conversationOp` whose whole-log fold answers the different question of which visible generation is current. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 631ba71d84..f0402d0f7a 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md @@ -12,19 +12,19 @@ Status: implemented ## 决策 -模型投影与人类投影是分开的,而事件属于哪一种由事件自身的标记决定。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源的事件是 transcript 的持久来源,替换副本仅供模型使用。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。 +模型投影与人类投影是分开的。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源事件是 transcript 的默认持久来源;除非替换型 `user/message` 携带[同会话用户消息编辑](../feature/2026-09-01-same-session-user-message-edit.zh.md)引入的显式 `conversationOp`,替换副本都只供模型使用。该操作开启一代新的可见对话,并隐藏其声明的原始事件区间而不删除它。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。 终端从追加来源的 surface 事件回放 transcript,并通过 `transcriptToolCallIds` 让被遮蔽步骤的工具卡片保持配对:该函数读取追加来源的 `assistant/message`,而不是 surface 成员关系。已落地的压缩会在其自身日志位置贡献一行暗色 `… earlier context was compacted …`:这行标记报告模型从何处起不再看到那段历史,而不是把它抹掉。带框的检查点载荷从不渲染,且两条路径都按同一个标记对 surface 事件分类,因此实时到达的压缩与恢复后回放同一份日志会产生相同的 transcript。只有回放会重新推导 `tool/call` 的配对关系:调用事件自身不携带标记,其归属继承自公布它的 `assistant/message`,而实时监听器必然刚刚渲染过后者。 -检查点通过压缩 seam 自身的约定来识别——`isCompactCheckpointSource`,即 `CompactionEngine` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。其他替换保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。 +检查点通过压缩 seam 自身的约定来识别——`isCompactCheckpointSource`,即 `CompactionEngine` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇,而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志;这里只是另一个读者提出同样的问题。没有 `conversationOp` 的替换事件保持静默:被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点,并不在对话中标出边界。 -`session.history` 只把追加来源的消息计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compaction/summary` 事件会与引用它的替换留在同一页。 +`session.history` 把追加来源消息与显式对话替换计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compaction/summary` 事件会与引用它的替换留在同一页,编辑后的代次也可以保留其隐藏原始区间用于无损回放。 -持久事件、RPC 信封、压缩事务与模型可见的 surface 都没有变化,也不需要迁移。 +压缩仍不改变用户可见替换状态。同会话 Edit 增加独立的持久 `conversationOp` 与 RPC 操作,不改变压缩事务。 ## 延后事项 -浏览器客户端在[Web transcript 投影笔记](2026-07-30-web-transcript-log-ordered-projection.zh.md)中单独修复:它按日志顺序投影同一份追加来源 transcript 并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会在检查点与检查点引用的来源事件这个整体内切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但当检查点是最旧的被计数消息时,旧分页规则会把整段被遮蔽的范围放在同一页。 +浏览器客户端在[Web transcript 投影笔记](2026-07-30-web-transcript-log-ordered-projection.zh.md)中单独修复:它按日志顺序投影默认的追加来源 transcript,在组装前应用显式编辑代次区间,并渲染一个压缩标记组件。它同时闭合本次变更打开的分页缺口——因为 `session.history` 不再为检查点消耗额度,它永远不会在检查点与检查点引用的来源事件这个整体内切分,于是一页可以携带一个引用了窗口之外 `surfaceOp.start` 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但当检查点是最旧的被计数消息时,旧分页规则会把整段被遮蔽的范围放在同一页。 终端的[已归档实时压缩进度决策](../../archived/feature/2026-07-30-compaction-progress-visibility.md)使用独立标记对中的事件驱动现有的单格指示器。它既不改变本文所负责的完成标记,也不添加规模信息:检查点的 `sourceEventSeqs` 仍可供经另行论证的计数或区间使用。因此,进度显示既不需要修改标记内容,也不以提取 `renderReplacement(event)` 为前置条件。 @@ -38,7 +38,7 @@ Status: implemented **用 `compaction/*` 括号而不是检查点来推导标记。** 就 transcript 而言被否决:括号是围绕一次操作的一对时间点标记,而 transcript 需要的是 surface 真正发生变化的位置。括号适合作为进度与耗时的来源,而本次变更并不渲染这些。 -**像 `session-query` 为搜索所做的那样重新折叠日志来分类事件(`current`/`shadowed`/`log-only`)。** 被否决:折叠回答的是整份日志的问题,而投影问的是逐事件的问题,事件自身的标记已能以常数时间给出答案。 +**像 `session-query` 为搜索所做的那样,通过模型 surface 折叠分类每个替换(`current`/`shadowed`/`log-only`)。** 被否决:压缩的 transcript 成员关系仍是逐事件标记问题。同会话 Edit 改用独立的 `conversationOp`;其整日志折叠回答的是另一项问题——哪一代可见对话是当前代次。 ## 后果 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml index deb8efc160..9fba3b7c24 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.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/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md -2026-07-30-web-transcript-log-ordered-projection.md: ed6fa4df3ac25bf6fe2947e6fb7bb7cbde6ac00a -2026-07-30-web-transcript-log-ordered-projection.zh.md: cf8dc35099f416356310081f758d0b89fe8282b9 +2026-07-30-web-transcript-log-ordered-projection.md: 77873cd9df32d1c7970731a8158284d1b4869a82 +2026-07-30-web-transcript-log-ordered-projection.zh.md: 7ccc2733dab18ccb326558bbb939c152b21d7f2b diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md index ed6fa4df3a..77873cd9df 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.md @@ -12,7 +12,7 @@ Surface order made two further problems structural. It is not seq-ascending afte ## Decision -`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It projects the raw window in log order: every append-origin surface event (`isAppendSurfaceEvent`) at its own log position, plus one `CompactionSummaryNode` marker per landed compaction checkpoint. A landed compaction therefore keeps the conversation it shadowed on the model side, and the marker reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two are now separate on both frontends. +`TranscriptAdapter` replaces `FoldAdapter` and never consults surface order. It projects the retained raw window in log order: append-origin surface events (`isAppendSurfaceEvent`) at their own log positions, one `CompactionSummaryNode` marker per landed compaction checkpoint, and the explicit replacement `user/message` from [same-session user-message editing](../feature/2026-09-01-same-session-user-message-edit.md). Before assembly, that feature's `conversationOp` fold removes the prior visible generation it names. A landed compaction therefore keeps the conversation it shadowed on the model side, while an Edit deliberately replaces its old user-facing range. Other model-only replacement copies stay out of the transcript: a pruned `tool/result` and a regenerated `assistant/message` rewrite one node for the model and mark no boundary in the conversation. Everything that must send exactly what the model sees keeps reading the surface; this is the human projection, and the two remain separate on both frontends. Node order is seq-monotonic by construction, and three things follow. The log-only `command/run` / `command/done` pair folds into `CommandNode`s that splice into an already-monotonic array by seq — no anchors, no reordering. `Session` keeps ownership of interrupted frozen nodes and merges them by their fractional seqs with a plain sort, which is now exactly flow order. And a window whose checkpoint cites a shadowed range outside it has no range to resolve, so the marker renders and nothing is logged. @@ -69,7 +69,7 @@ Compaction no longer erases web history; a session compacted several times shows `ConversationNode` gains an eighth arm, so every exhaustive consumer grew one case: `MessageItem` renders the marker through the new `CompactionItem`, and the trajectory layout widens its no-cell arm so a marker contributes no cell but still advances the duration cursor. -The performance contract is unchanged and now simpler to state: one append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not even recomputed — and unchanged nodes keep their object identity. The window still grows with session length rather than with the surface, which is the trade the fix exists to make; a compaction used to bound the projection for exactly the long sessions compaction serves. +The incremental performance contract remains: one ordinary append materializes one node, an event that changes no node keeps the previous array reference — so a chunk storm costs nothing and `nodes()` is not recomputed — and unchanged nodes keep their object identity. A `conversationOp` append deliberately rebuilds the loaded window once so every target publishes one coherent generation. The raw window still grows with session length even when Edit hides older generations, which preserves lossless paging at the cost of scanning retained events during that rebuild. The web e2e scenario now seeds a real manual command lifecycle around a compaction transaction over its recorded turn, so the aria golden pins the complete behavior through the real host and a real browser: the recorded prompt and full tool output are still on screen, exactly one `compact` row reports scale after them, and its disclosure opens the exact summary. The seed recording itself is untouched and stays model-authentic — replay derives the manual compaction from the recording's own surface. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md index cf8dc35099..7ccc2733da 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-30-web-transcript-log-ordered-projection.zh.md @@ -12,7 +12,7 @@ surface 顺序还让另外两个问题成为结构性的。一次替换之后它 ## Decision -`TranscriptAdapter` 取代 `FoldAdapter`,并且从不查询 surface 顺序。它按日志顺序投影原始窗口:每个 append 来源的 surface 事件(`isAppendSurfaceEvent`)落在它自己的日志位置上,外加每次落地的压缩检查点一个 `CompactionSummaryNode` 标记。于是一次落地的压缩会保留它在模型侧遮蔽掉的对话,标记报告模型从哪里开始看不见那段历史,而不是把它抹掉。仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不在对话中标记任何边界。凡必须发送模型所见内容的一切仍读 surface;这是人类投影,两者现在在两个前端上都已分离。 +`TranscriptAdapter` 取代 `FoldAdapter`,并且从不查询 surface 顺序。它按日志顺序投影保留的原始窗口:append 来源的 surface 事件(`isAppendSurfaceEvent`)落在各自日志位置上,每次落地的压缩检查点对应一个 `CompactionSummaryNode` 标记,[同会话用户消息编辑](../feature/2026-09-01-same-session-user-message-edit.zh.md)则提供显式替换 `user/message`。组装前,该功能的 `conversationOp` 折叠会移除它所标识的先前可见代次。因此,一次落地的压缩会保留它在模型侧遮蔽的对话,而一次 Edit 会有意替换其旧用户可见区间。其他仅模型可见的 replacement 副本不进入记录:被裁剪的 `tool/result` 和重新生成的 `assistant/message` 只为模型重写一个节点,不在对话中标记任何边界。凡必须发送模型所见内容的一切仍读 surface;这是人类投影,两者在两个前端上继续分离。 节点顺序天然按 seq 单调,由此有三个结果。仅日志的 `command/run` / `command/done` 对折叠成 `CommandNode`,按 seq 插入一个本已单调的数组——无锚点,无重排。`Session` 保留被打断的冻结节点的归属,用一次普通排序按其分数 seq 归并,而这现在恰好就是流顺序。检查点所引被遮蔽范围落在窗口之外的窗口没有范围需要解析,因此标记正常渲染且不打印任何日志。 @@ -69,7 +69,7 @@ const COMPACT_PLUGIN: CompactionCheckpointSource['plugin'] = 'compact' `ConversationNode` 增加第八个分支,因此每个穷尽消费方都多一个分支:`MessageItem` 通过新的 `CompactionItem` 渲染标记,trajectory 布局加宽它的“无单元格”分支,使标记不贡献单元格但仍推进耗时游标。 -性能约定未变,且现在更易表述:一次追加物化一个节点,不改变任何节点的事件保持上一次的数组引用——因此分片风暴零成本、`nodes()` 甚至不会重算——未变化的节点保持其对象标识。窗口仍随会话长度而非随 surface 增长,这正是本修复存在所要做的交换;一次压缩过去恰好为压缩所服务的长会话限制了投影规模。 +增量性能约定保持不变:一次普通 append 物化一个 node;不改变 node 的 event 保留旧数组引用——因此 chunk 风暴不产生任何开销,也不会重新计算 `nodes()`——未变化的 node 仍保持对象 identity。一次 `conversationOp` append 会有意重建一次已加载窗口,使每个 target 只发布一份完整代次。即使 Edit 隐藏较旧代次,原始窗口仍随 Session 长度增长;这保留了无损分页,但替换重建时需要扫描保留事件。 Web e2e 场景现在围绕它录制的那一轮上的压缩事务播种一次真实的手动命令生命周期,因此 aria 基准经真实宿主与真实浏览器钉住完整行为:录制的提问与完整工具输出仍在屏幕上,其后恰好一条 `compact` 行报告规模,展开后会显示确切摘要。录制本身未被触碰、保持模型真实——回放从录制自身的 surface 派生出手动压缩。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.i18n.yaml similarity index 56% rename from .agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml rename to .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.i18n.yaml index e23f925416..285a2abcb8 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-drop-user-message-edit-stub.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.i18n.yaml @@ -1,6 +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/simplification/2026-07-31-drop-user-message-edit-stub.md -2026-07-31-drop-user-message-edit-stub.md: 5a34be1dd285f4aced9e6cfb2e324b20ec734bed -2026-07-31-drop-user-message-edit-stub.zh.md: 288f2bda5d41cf0a331f69f134c8cf1e61a801ef +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md +2026-09-01-same-session-user-message-edit.md: 15f6bb36606037ddc04126195fcfeb645a61a3b3 +2026-09-01-same-session-user-message-edit.zh.md: 16eb5e156016a27d075bb1eccbb0358273d0f758 diff --git a/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md new file mode 100644 index 0000000000..15f6bb3660 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.md @@ -0,0 +1,49 @@ +# Agent Note: Same-session user-message editing + +Status: implemented + +English | [中文](2026-09-01-same-session-user-message-edit.zh.md) + +## Problem + +Correcting a sent message required creating a fork even when the user wanted to keep working in the same Session. The [earlier removal of the unbacked Edit control](../../archived/simplification/2026-07-31-drop-user-message-edit-stub.md) correctly withheld that affordance until a Host operation existed. Reusing model-surface replacement alone was insufficient: compaction intentionally removes old messages only from model context, while Edit must also replace the visible Chat, Trajectory, turn-navigation, title-input, and search generation without deleting audit data. A running turn, queued prompts, attachments, and session-reference context also make a same-session retry more than a UI text mutation. + +## Decision + +The ordinary-Session `session.edit` operation accepts only the latest human `user/message`, and that message must open a turn and remain in the current model surface. The request identifies the target event and carries the latest human-message seq observed when the editor opened. The Host validates both before any interruption and repeats validation while it owns idle maintenance; a later human message makes the request stale. Earlier messages, direct subagent Sessions, steering messages, compacted messages, and messages without editable text are rejected. + +If the Agent is running, Edit cancels the active turn with `keepInbox: true` and reserves maintenance synchronously at the idle transition. It inserts the replacement at the front of `next-turn`, so the edited rerun precedes existing Queue work without deleting it. The durable inbox admission carries the replacement's `SurfaceIntent` and any retained session-reference messages; AgentLoop expands those companions immediately after the edited prompt and follows the primary message by `MessageId` through `agent/pre-step` rewrites. The pre-step payload exposes claimed intents, and automatic compaction defers while a preplanned surface replacement is pending so its coordinates remain valid through commit. A restart after admission therefore preserves both replacement semantics and reference ordering. + +The replacement message keeps the target's non-text content, uses the submitted text and a new timestamp, and runs with the Session's current model selection. An image-bearing edit validates that model before interrupting active work. Existing session-reference recall messages are copied rather than regenerated, so the rerun uses the snapshot the original prompt cited. Edit does not rewind files, processes, background work, subagents, or any other external side effect. + +The committed replacement `user/message` carries two independent operations. Its `surfaceOp` replaces the current model-message suffix from the edited turn onward, and `sourceEventSeqs` names every removed surface node. Its `conversationOp` hides the inclusive raw-event range from that turn's `turn/start` through the pre-admission log tail from current user-facing projections. All old events remain in the append-only log. + +`ui-conversation` folds `conversationOp` ranges before every target assembles nodes and rebuilds the loaded window atomically when a replacement arrives. Chat therefore never publishes a mixed old/new generation. The turn outline removes hidden turns. A replacement cancels pending or active automatic title work without scheduling a new revision; a later ordinary title revision reads only the current conversation generation. Session Query classifies every searchable event in a hidden edit range as `shadowed`; model-facing search defaults to `current` plus `log-only` and does not expose `shadowed`, while exact reads and traces remain available for diagnostics. Lossless Session export and whole-log statistics retain both generations. + +Chat exposes Edit only on that latest eligible message. Editing uses a full Chat-width, composer-style input card with input-surface fill, border, elevation, and an internal cancel/save row. The textarea starts at 80px, grows with its content to 240px, and then scrolls internally. Enter submits, Shift+Enter inserts a newline, Escape cancels, and the normal composer remains enabled. Starting another submission closes the editor. Submission immediately replaces the selected suffix with a local echo; failure restores the durable view, while success hands off to the replacement event. The result has no edited badge or Undo action, and the existing Session title remains unchanged. + +The shipped scope is the Edit part of [issue #2351](https://github.com/deepseek-harness/deepseek-harness/issues/2351). Fork and Rewind remain separate behavior. + +## Alternatives considered + +**Always fork before rerunning.** Rejected because it changes Session identity and leaves the user to choose between correction and branching. Fork remains useful when both histories should stay independently navigable. + +**Mutate or delete the original events.** Rejected because Session persistence, replay, diagnostics, and lossless export depend on an append-only log. A replacement event records the new generation while retaining the prior facts. + +**Treat every `SurfaceOp` replacement as a human transcript replacement.** Rejected because compaction and result pruning intentionally change model context without erasing what the user already saw. `conversationOp` is explicit and independent. + +**Reuse the pending Queue editor.** Rejected because a queued message has not entered model history, while a historical edit must replace consumed context, interrupt current work safely, and rerun. + +**Clear Queue or apply it in the edited request.** Rejected because Queue entries remain user-owned future turns. The edited turn runs first and leaves a window in which the user may still remove queued work. + +**Provide Undo together with Edit.** Rejected because undoing after new model output requires another explicitly defined generation replacement and conflict policy. The retained raw events preserve the data without advertising an unsupported reversal. + +## Consequences + +One edit adds a new turn plus durable inbox and replacement metadata; storage, lossless export, and whole-log statistics grow with every generation. Current conversation views and default model search omit replaced generations, while diagnostic reads can still inspect them. + +Conversation-range lookup is logarithmic in the number of merged edit ranges for each event. The SQLite provider already rebuilds documents for a changed live Session; Edit adds classification work but no second text index or delete/reinsert protocol for historical rows. + +The unchanged prefix before the first replaced model message remains eligible for provider KV-cache reuse. The edited suffix starts a new request series and incurs normal request and response tokens. + +Unit coverage pins range validation and folding, durable inbox replay, pre-step intent retention, Queue priority, interruption, attachment and reference preservation, title and outline behavior, search classification, and optimistic UI handoff. The Web replay e2e pins edit, rerun, hidden old output, and refresh reconstruction through the real Host and browser. diff --git a/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md new file mode 100644 index 0000000000..16eb5e1560 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-01-same-session-user-message-edit.zh.md @@ -0,0 +1,49 @@ +# Agent Note: 同会话用户消息编辑 + +Status: implemented + +[English](2026-09-01-same-session-user-message-edit.md) | 中文 + +## 问题 + +即使用户希望留在同一个 Session 中继续工作,修正已发送消息也必须创建 fork。[此前移除无后端 Edit 控件的决策](../../archived/simplification/2026-07-31-drop-user-message-edit-stub.md)正确地在 Host 操作存在前隐藏了这项可供性。仅复用模型 surface 替换并不够:压缩有意只从模型上下文移除旧消息,而 Edit 还必须替换 Chat、Trajectory、轮次导航、标题输入和搜索中的可见对话代次,同时不能删除审计数据。运行中的轮次、已排队提示词、附件和会话引用上下文也使同会话重跑不只是一次 UI 文本修改。 + +## 决策 + +普通 Session 的 `session.edit` 操作只接受最新的人工 `user/message`,且该消息必须用于开启轮次并仍位于当前模型 surface。请求标识目标事件,并携带编辑器打开时观测到的最近人工消息 seq。Host 会在任何中断前进行校验,并在持有 idle maintenance 时再次校验;后续人工消息会使请求变为 stale。更早的消息、direct subagent Session、steering 消息、已压缩消息和没有可编辑文本的消息都会被拒绝。 + +如果 Agent 正在运行,Edit 会用 `keepInbox: true` 取消活跃轮次,并在其进入 idle 时同步取得 maintenance。它把替换消息插到 `next-turn` 前端,因此编辑重跑先于已有 Queue 工作执行,但不会删除 Queue。持久 inbox 准入会携带替换消息的 `SurfaceIntent` 与所有保留的会话引用消息;AgentLoop 在编辑提示词之后立即展开这些配套消息,并通过 `MessageId` 让非默认放置方式穿过 `agent/pre-step` 重写。pre-step payload 会公开已领取的 intent,主动压缩在预定 surface 替换待提交时推迟,因此替换坐标在提交前保持有效。即使准入后发生重启,替换语义和引用顺序仍会保留。 + +替换消息保留目标消息的非文本内容,使用提交的新文本和新时间戳,并采用 Session 当前的模型选择。带图片的编辑会在中断活跃工作前验证该模型。已有会话引用 recall 消息会被复制而不重新生成,因此重跑使用原提示词引用的快照。Edit 不会回退文件、进程、后台任务、subagent 或任何其他外部副作用。 + +已提交的替换 `user/message` 携带两个独立操作。其 `surfaceOp` 替换从编辑轮次开始的当前模型消息后缀,`sourceEventSeqs` 列出每个被移除的 surface 节点;其 `conversationOp` 从当前用户可见投影中隐藏自该轮 `turn/start` 到准入前日志末尾的原始事件闭区间。所有旧事件仍保留在仅追加日志中。 + +`ui-conversation` 在每个 target 组装 node 前折叠 `conversationOp` 区间,并在替换到达时原子重建已加载窗口,因此 Chat 不会发布新旧代次混合的中间状态。轮次大纲会移除被隐藏的轮次。替换消息会取消待执行或正在运行的自动标题工作,但不会安排新的 revision;后续普通标题 revision 只读取当前对话代次。Session Query 把编辑隐藏区间内每个可搜索事件分类为 `shadowed`;面向模型的搜索默认只查询 `current` 与 `log-only`,且不公开 `shadowed`,精确读取与追踪仍可用于诊断。无损 Session 导出与全日志统计保留两代内容。 + +Chat 只在该最新且符合条件的消息上公开「编辑消息」。编辑使用铺满 Chat 内容列的 composer 风格输入卡片,带输入表面背景、边框、阴影和卡片内部的取消/保存操作行。文本框初始高度为 80px,随内容增长至 240px 后改为内部滚动。Enter 提交,Shift+Enter 插入换行,Escape 取消,普通 composer 保持可用。开始另一条提交会关闭编辑器。提交会立刻用本地回显替换选中后缀;失败恢复持久视图,成功则交接给替换事件。结果不显示已编辑标记,也没有 Undo,已有 Session 标题保持不变。 + +已交付范围是 [issue #2351](https://github.com/deepseek-harness/deepseek-harness/issues/2351) 的 Edit 部分。Fork 与 Rewind 仍是独立行为。 + +## 曾考虑的替代方案 + +**每次重跑前都创建 fork。** 被否决,因为它会改变 Session 身份,并迫使用户在修正与分支之间做选择。需要让两份历史都可独立导航时,fork 仍然有用。 + +**修改或删除原事件。** 被否决,因为 Session 持久化、回放、诊断与无损导出都依赖仅追加日志。替换事件记录新代次,同时保留先前事实。 + +**把每个 `SurfaceOp` 替换都视为人类 transcript 替换。** 被否决,因为压缩与结果裁剪有意改变模型上下文,却不抹掉用户已经看到的内容。`conversationOp` 是显式且独立的机制。 + +**复用待处理 Queue 编辑器。** 被否决,因为已排队消息尚未进入模型历史,而历史编辑必须替换已消费上下文、安全中断当前工作并重新运行。 + +**清空 Queue,或把它应用到编辑请求中。** 被否决,因为 Queue 条目仍是用户拥有的未来轮次。编辑轮次先运行,并留出用户继续删除排队工作的时间窗口。 + +**让 Undo 与 Edit 同时提供。** 被否决,因为在新模型输出出现后撤销需要再定义一套显式代次替换和冲突策略。保留的原始事件保存了数据,但不会宣告尚不受支持的反转操作。 + +## 后果 + +每次编辑都会增加一个新轮次,以及持久 inbox 与替换元数据;存储、无损导出和全日志统计都会随每一代内容增长。当前对话视图与默认模型搜索省略被替换代次,诊断读取仍可检查它们。 + +对每个事件进行对话区间成员判断的复杂度,与合并后编辑区间数量呈对数关系。SQLite 提供方本就会为变化后的实时 Session 重建文档;Edit 增加分类工作,但不引入第二份文本索引,也不需要删除并重插历史行。 + +首条被替换模型消息之前未变化的前缀仍可复用提供方 KV Cache。编辑后的后缀会开启新的请求序列,并正常产生请求与响应 token。 + +单元测试固定区间校验与折叠、持久 inbox 回放、pre-step intent 保留、Queue 优先级、中断、附件与引用保留、标题与大纲行为、搜索分类和乐观 UI 交接。Web replay e2e 通过真实 Host 与浏览器固定编辑、重跑、隐藏旧输出和刷新重建。 diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index 2fba524c5d..3247c10cce 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -10,6 +10,8 @@ - text: Render adjacent CJK strong emphasis. {{clock}} - button "Copy": - img +- button "Edit message": + - img - heading "CJK strong emphasis" [level=2] - paragraph: - strong: 注意: diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 3843143d1f..92d307148f 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -10,6 +10,8 @@ - text: Show the Markdown image policy. {{clock}} - button "Copy": - img +- button "Edit message": + - img - heading "Markdown images" [level=2] - paragraph: - img "Remote test image" diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 2d2127cbb2..2f09de2539 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -10,6 +10,8 @@ - text: Show the local preview URL. {{clock}} - button "Copy": - img +- button "Edit message": + - img - heading "Inline code links" [level=2] - paragraph: - text: "Preview:" diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index fba05ffe71..73df5ca99b 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -10,6 +10,8 @@ - text: Render this mathematical proof. {{clock}} - button "Copy": - img +- button "Edit message": + - img - heading "Math rendering" [level=2] - paragraph: - text: Inline dollar diff --git a/apps/web/tests/expected/message-edit/replay.override.json b/apps/web/tests/expected/message-edit/replay.override.json new file mode 100644 index 0000000000..cc28bb4c5e --- /dev/null +++ b/apps/web/tests/expected/message-edit/replay.override.json @@ -0,0 +1,12 @@ +[ + { + "kind": "chunks", + "chunks": [ + { "type": "block-start", "index": 0, "blockType": "text" }, + { "type": "text-delta", "index": 0, "text": "EDITED_OK" }, + { "type": "block-end", "index": 0, "block": { "type": "text", "text": "EDITED_OK" } }, + { "type": "usage", "usage": { "inputTokens": 10, "outputTokens": 2, "cacheReadTokens": 0, "reasoningTokens": 0 } }, + { "type": "finish", "reason": { "kind": "stop" } } + ] + } +] diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 7cec2eaf08..324cf5f8c2 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -10,6 +10,8 @@ - text: Research notes what changed? Referenced session · Research notes {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Session recall Research notes": - img - text: Session recall Research notes diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index 07be7102ff..1ee0e77114 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -16,6 +16,8 @@ - text: /user-invoke-demo and confirm the fixture wiring {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Thought for a while" [expanded]: - text: Thought for a while - img diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 7ddf071159..a0adedfb64 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -16,6 +16,8 @@ - text: /user-invoke-demo and confirm the fixture wiring {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Thought for a while": - text: Thought for a while - img diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index fc8039e282..e8a2d5f4f3 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -444,6 +444,8 @@ - text: 7/25 {{clock}} m28 7/25 {{clock}} - button "Copy": - img +- button "Edit message": + - img - paragraph: r28 - button "Copy": - img diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index c3847df394..048390e038 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -16,6 +16,8 @@ - text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index c29d9cf977..5535fd43b9 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -114,8 +114,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) // Focus-reveal the footers (hover:hover keeps them opacity-hidden until - // hover/focus-within). Branch renders only under assistant answers — user - // bubbles carry none — and only a completed transcript tail enables it. + // hover/focus-within). Branch renders only under assistant answers, while + // only the latest current turn-opening user message exposes Edit. const copyButtons = page.getByRole('button', { name: 'Copy' }) await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(4) await copyButtons.first().focus() @@ -128,7 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { await branchButtons.first().focus() await expect.poll(() => page.getByRole('tooltip').textContent(), { timeout: 5_000 }) .toBe('Available only on the last message of a completed turn') - await expect.poll(() => page.getByRole('button', { name: 'Edit' }).count(), { timeout: 5_000 }).toBe(0) + await expect.poll(() => page.getByRole('button', { name: 'Edit message' }).count(), { timeout: 5_000 }).toBe(1) }, 60_000) it.skipIf(MODE === 'record')('matches the conversation aria golden with IconActions and clocks', async () => { diff --git a/apps/web/tests/message-edit.e2e.ts b/apps/web/tests/message-edit.e2e.ts new file mode 100644 index 0000000000..a944915395 --- /dev/null +++ b/apps/web/tests/message-edit.e2e.ts @@ -0,0 +1,90 @@ +// Web e2e: same-session user-message Edit replaces the selected turn and +// reruns directly without creating a fork or exposing the old generation. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/message-edit', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.jsonl', import.meta.url)) +const OVERRIDE = fileURLToPath(new URL('./expected/message-edit/replay.override.json', import.meta.url)) +const UI_EXPECTED = join(SNAPSHOT_DIR, 'ui.expected.md') +const MODE = webSnapshotMode() +const SESSION_ID = 'message-edit-web-e2e' +const ORIGINAL = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +const EDITED = 'Use the existing context and reply with exactly EDITED_OK.' + +describe('web e2e: edit a historical user message in the same Session', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + replayFixture: SEED, + replayOverride: OVERRIDE, + compareReplaySession: false, + }) + await seedSession(scaffold, await readFile(SEED, 'utf8'), SESSION_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('replaces the old generation, reruns, and survives refresh', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-message-edit')) + await page.locator('[role="treeitem"]').first().click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.click() + await page.getByText(ORIGINAL, { exact: true }).waitFor({ timeout: 15_000 }) + + await page.getByRole('button', { name: 'Edit message' }).click() + const editor = page.getByRole('textbox', { name: 'Edit message' }) + await editor.fill(EDITED) + const settled = scaffold.whenTurnSettled(30_000) + await editor.press('Enter') + await settled + + await page.getByText(EDITED, { exact: true }).waitFor({ timeout: 15_000 }) + await page.getByText('EDITED_OK', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await page.getByText(ORIGINAL, { exact: true }).count()).toBe(0) + expect(await page.getByText('DONE', { exact: true }).count()).toBe(0) + const live = scaffold.ctx.sessions.get(SessionId(SESSION_ID)) + const replacement = live?.snapshotEvents().findLast((event): event is SessionEvent<'user/message'> => + event.type === 'user/message' && event.conversationOp !== undefined) + expect(replacement).toMatchObject({ + data: { content: [{ type: 'text', text: EDITED }] }, + surfaceOp: { op: 'replace' }, + conversationOp: { op: 'replace' }, + }) + + await page.reload({ waitUntil: 'load' }) + await page.getByText(EDITED, { exact: true }).waitFor({ timeout: 15_000 }) + await page.getByText('EDITED_OK', { exact: true }).waitFor({ timeout: 15_000 }) + expect(await page.getByText(ORIGINAL, { exact: true }).count()).toBe(0) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) + + it.skipIf(MODE === 'record')('keeps the expected artifact inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index cd696c5411..20b34b50c4 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -16,6 +16,8 @@ - text: Stream one TypeScript fence for the highlighting snapshot. {{clock}} - button "Copy": - img +- button "Edit message": + - img - button "Context injection @deepseek-ai/dsh-system-prompt": - img - img diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 114c7ca082..37685410a3 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -63,6 +63,7 @@ "tests/cordis-tool-round.e2e.ts", "tests/web-search-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/message-edit.e2e.ts", "tests/message-feedback.e2e.ts", "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 14c3c8ad07..caac6208a9 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.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/architecture.md -architecture.md: 902fd7b53fe7493127da68f9a8f381a33b8edc18 -architecture.zh.md: 2890b32b5db1ac30f6eafef47b019a03c6acedd4 +architecture.md: cf337b12c3ec4050e85618931f8e26a5d8bcc0ec +architecture.zh.md: 3bac2917e22722bbcda7820d2d4673430dfe4e45 diff --git a/docs/architecture.md b/docs/architecture.md index 902fd7b53f..cf337b12c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,15 +94,15 @@ turn/end `turn/*`, `step/*`, `user/message`, `assistant/*`, and `tool/*` are durable session events; the rest are live extension points across three domains. `agent/pre-step`, `agent/request`, `llm/stream`, and the three `tools/*` events are waterfalls, whose listeners must call `next()` to delegate; `agent/turn-stopping` is serial and has no `next()`. -Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. +Input reaches the driver through one inbox. Some messages wake it immediately; injected context waits in the inbox until another message does. An inbox admission may retain non-default Session placement and ordered same-step companion messages with its primary `MessageId`, so replay and pre-step rewriting preserve the complete admitted batch. -`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. +`agent/pre-step` decides what the model sees. Listeners may rewrite the claimed messages or reject them outright; a rejected or empty first claim still closes a durable turn that spent no step, so the log records the attempt. Non-default Session placement follows a surviving message by identity, and the payload exposes those intents so a listener that mutates the Session surface can defer while a preplanned replacement is pending. An enter decision may also set `startsRequestSeries` to begin a distinct model-message series: the loop then logs a fresh `request/header` (reason `series`, or `change` carrying `startsSeries: true` when the envelope changed too). A listener that rebuilds a downstream enter decision must spread it (`{ ...decision, messages }`) so the declaration survives. Each step reads the prompt sections and tool schemas that plugins registered. Details: the [sequence diagram](agent-lifecycle.md), the [tool pipeline](tool-execution-pipeline.md), and [cancellation and error recovery](subsystems/core.md#the-agent-handle). ## Session log -The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. Fork, resume, transcripts, telemetry, and persistence all derive from this stream. +The session log is the source of the context the model sees. `deriveMessages()` projects model history from it, and raw `assistant/chunk` events preserve replay and UI fidelity. `SurfaceOp` owns model-history replacement; an explicit `conversationOp` on a replacement `user/message` independently hides one raw-event range from current user-facing projections while the append-only log remains lossless. Fork, resume, transcripts, telemetry, and persistence all derive from this stream. **Model-visible means logged.** Anything that reaches a model request must be reconstructable from the log, and a runtime invariant asserts it. This is why a new model-visible input requires a new session event: extend `SessionEventMap` and render from the log. @@ -135,6 +135,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Intercept a request, tool, or turn | use its `agent/*` or `tools/*` event; `agent/turn-stopping` stops a turn | | Add model-facing context | call `agent.inject()`; it lands in the next admitted request | | Add UI or editor integration | drive `ctx.agents` and render from `session/event` | +| Edit the latest human message | call the ordinary-Session `session.edit` RPC; when it remains the current turn-opening prompt, the RPC appends a replacement generation in the same Session | | Add a Web Client Chat node | register a `ConversationNodeDefinition` + keyed renderer | | Add durable session state | extend `SessionEventMap`; render and replay from the log | | Generate session titles | register the sole `ctx.sessionTitle` provider | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 2890b32b5d..3bac2917e2 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -98,15 +98,15 @@ turn/end `turn/*`、`step/*`、`user/message`、`assistant/*` 和 `tool/*` 是持久会话事件;其余是分属三个事件域的实时扩展点。`agent/pre-step`、`agent/request`、`llm/stream` 和三个 `tools/*` 事件是 waterfall(瀑布式事件),其监听器必须调用 `next()` 才能委托下去;`agent/turn-stopping` 是 serial 事件,没有 `next()`。 -输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。 +输入通过同一个 inbox 到达驱动器。有些消息会立即唤醒它;注入的上下文会留在 inbox 中,直到另一条消息将其唤醒。一次 inbox 准入可以随主消息的 `MessageId` 保留非默认 Session 放置方式和有序的同一步骤配套消息,因此回放与 pre-step 重写都能保留完整准入批次。 -`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 +`agent/pre-step` 决定模型看到什么。监听器可以改写已领取的消息,也可以直接拒绝它们;首次领取被拒绝或被改写为空时,仍会关闭一个不含步骤的持久轮次,因此日志会记录这次尝试。非默认 Session 放置方式按身份跟随仍被保留的消息,payload 也会公开这些 intent,使会修改 Session surface 的监听器能在预定替换待提交时推迟操作。enter 决策还可以设置 `startsRequestSeries` 来开启独立的模型消息序列:loop 会随之记录一个新的 `request/header`(原因为 `series`,或在封装同时变化时为携带 `startsSeries: true` 的 `change`)。重建下游 enter 决策的监听器必须展开它(`{ ...decision, messages }`),该声明才能存活。每个步骤读取插件注册的提示词片段和工具 schema。 详情见[时序图](agent-lifecycle.zh.md)、[工具流水线](tool-execution-pipeline.zh.md)和[取消与错误恢复](subsystems/core.zh.md#the-agent-handle)。 ## 会话日志 -会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史,原始 `assistant/chunk` 事件则保证回放和 UI 保真。fork、恢复、transcript(文本记录)、遥测和持久化都派生自该事件流。 +会话日志是模型所见上下文的来源。`deriveMessages()` 从中投影出模型历史,原始 `assistant/chunk` 事件则保证回放和 UI 保真。`SurfaceOp` 负责替换模型历史;替换型 `user/message` 上的显式 `conversationOp` 独立地从当前用户可见投影隐藏一段原始事件区间,而仅追加日志保持无损。fork、恢复、transcript(文本记录)、遥测和持久化都派生自该事件流。 **模型可见即已记录。** 抵达模型请求的一切都必须能从日志重建,并由一项运行时不变量断言这一点。因此,新增一项模型可见输入就需要新增一个会话事件:扩展 `SessionEventMap` 并从日志渲染。 @@ -139,6 +139,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 拦截请求、工具或轮次 | 使用相应的 `agent/*` 或 `tools/*` 事件;`agent/turn-stopping` 会停止轮次 | | 添加模型可见上下文 | 调用 `agent.inject()`;它会落到下一次获准的请求中 | | 添加 UI 或编辑器集成 | 驱动 `ctx.agents` 并从 `session/event` 渲染 | +| 编辑最新的人工消息 | 调用普通 Session 的 `session.edit` RPC;它仍是当前轮次开场提示词时,RPC 会在同一 Session 中追加替换代次 | | 添加 Web Client Chat 节点 | 注册 `ConversationNodeDefinition` + keyed renderer | | 添加持久会话状态 | 扩展 `SessionEventMap`;从日志渲染和回放 | | 生成会话标题 | 注册唯一的 `ctx.sessionTitle` 提供方 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 8ebe947f40..cea28cb232 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: 6a0f7f23885cd85ccb1ff20e9322f133767aed4a -config-catalog.zh.md: cae9fa49a6986130c8b7261cf41e04b4d6b4e0e1 +config-catalog.md: ced73f7a75dac2fb0c4daefe65a30c59346578a6 +config-catalog.zh.md: 9aa539d8fab02b56fd2a54d22e9233bbbbb74f9f diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6a0f7f2388..ced73f7a75 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -217,7 +217,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:74`](../packages/api/session-controller/src/index.ts) @@ -2012,7 +2012,7 @@ export interface Config { } ``` -Source: [`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:58`](../packages/session/session-title/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index cae9fa49a6..9aa539d8fa 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -219,7 +219,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:74`](../packages/api/session-controller/src/index.ts) @@ -2000,7 +2000,7 @@ export enum SessionTelemetryMode { ## `@deepseek-ai/dsh-session-title` -需要:`sessions` +需要:`sessions` · `sessionProjections` ```ts config-catalog /** Required deterministic fallback and accepted-title limits. */ @@ -2014,7 +2014,7 @@ export interface Config { } ``` -来源:[`packages/session/session-title/src/index.ts:56`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:58`](../packages/session/session-title/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 571af289d6..7c7d95b1b9 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: d04a37ee71756619b36de192011a0053e5ceccb8 -event-producer-consumer.zh.md: 226c368517d989cba2eeac515ee2c544151e8005 +event-producer-consumer.md: 57e15aae28a0eeb0cf7203fcfb2d054413a96624 +event-producer-consumer.zh.md: c0b3328e51467b23bc8945fa5f71f9fc607b2f67 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d04a37ee71..57e15aae28 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -9,23 +9,23 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:177`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:310`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:215`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:264`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:235`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:196`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:298`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:565`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -45,10 +45,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:94`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 226c368517..c0b3328e51 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -11,23 +11,23 @@ | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:246`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-presets/src/types.ts:80`](../packages/preset/agent-presets/src/types.ts) | [`agent-presets`](../packages/preset/agent-presets) (`emit`) | `remotes` | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:166`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:175`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:297`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:212`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:193`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:238`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:267`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:542`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:549`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:528`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:535`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:177`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), [`loader-smoke`](../packages/test-support/loader-smoke), [`schedule`](../packages/schedule/schedule), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:186`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:310`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:215`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:223`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:204`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:251`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-cordis`](../packages/extensions/tool-cordis), [`tool-skill`](../packages/skill/tool-skill), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:264`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:235`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:196`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:298`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:552`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:558`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:565`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | @@ -47,10 +47,10 @@ | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`llm`](../packages/llm/llm), `remotes` | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:67`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:52`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:62`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:83`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), `session-controller`, [`session-log-deepseek`](../packages/session/session-log-deepseek), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:73`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-presets`](../packages/preset/agent-presets), `agent-team`, [`compaction`](../packages/compaction/compaction), [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/test-support/loader-smoke), `server`, [`session`](../packages/core/session), `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tool-todo`](../packages/todo/tool-todo), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:94`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:105`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `remotes` | | `settings/updated` | `emit` | [`packages/settings/settings/src/types.ts:92`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:298`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index b6e2f8ac7f..1085669f1f 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-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/persistence-catalog.md -persistence-catalog.md: 1c0c6919987c691b82c4639aff0f779c95dca83c -persistence-catalog.zh.md: 4cc8ba5b7fc76708a80285013ebcbb03fe3e8e4a +persistence-catalog.md: b5ad3550d28b87dc76bdb7c71bf83c24f9843f05 +persistence-catalog.zh.md: a2823bd087165ac3853b0e9a7680daf6f6776880 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 1c0c691998..b5ad3550d2 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -48,7 +48,8 @@ export type SurfaceOp = * A proper discriminated union over `type` (not independent `type`/`data` * unions), so `switch (event.type)` narrows `event.data` without casts. * - * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp} + * fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry @@ -86,11 +87,13 @@ export type SessionEvent = { sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp + /** Raw event range this message replaces in current conversation projections. */ + conversationOp?: ConversationOp } : object) }[T] ``` -Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:436`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:449`](../packages/core/session/src/types.ts) ## Events @@ -111,11 +114,13 @@ Sources: [`packages/core/session/src/types.ts:368`](../packages/core/session/src start: number removedCount?: number inserted: UserMessage[] + /** Optional per-message placement and same-step companions. */ + admissions?: InboxAdmission[] outcome?: 'canceled' } ``` -Source: [`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:71`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -673,7 +678,7 @@ Source: [`packages/core/session/src/types.ts:364`](../packages/core/session/src/ Types: [SessionTitleEventData](subsystems/session-title.md) -Source: [`packages/session/session-title/src/index.ts:77`](../packages/session/session-title/src/index.ts) +Source: [`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts) diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 4cc8ba5b7f..a2823bd087 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -50,7 +50,8 @@ export type SurfaceOp = * A proper discriminated union over `type` (not independent `type`/`data` * unions), so `switch (event.type)` narrows `event.data` without casts. * - * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp} + * fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry @@ -88,11 +89,13 @@ export type SessionEvent = { sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp + /** Raw event range this message replaces in current conversation projections. */ + conversationOp?: ConversationOp } : object) }[T] ``` -来源:[`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:436`](../packages/core/session/src/types.ts) +来源:[`packages/core/session/src/types.ts:368`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:375`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:404`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:449`](../packages/core/session/src/types.ts) ## 事件 @@ -113,11 +116,13 @@ export type SessionEvent = { start: number removedCount?: number inserted: UserMessage[] + /** Optional per-message placement and same-step companions. */ + admissions?: InboxAdmission[] outcome?: 'canceled' } ``` -来源:[`packages/core/agent/src/types.ts:58`](../packages/core/agent/src/types.ts) +来源:[`packages/core/agent/src/types.ts:71`](../packages/core/agent/src/types.ts) ### `agent-preset/*` @@ -675,7 +680,7 @@ export type SessionEvent = { 类型:[SessionTitleEventData](subsystems/session-title.zh.md) -来源:[`packages/session/session-title/src/index.ts:77`](../packages/session/session-title/src/index.ts) +来源:[`packages/session/session-title/src/index.ts:79`](../packages/session/session-title/src/index.ts) diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index 68358c4f1e..f97b4994f2 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.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/conversation.md -conversation.md: df1476537b95690ae2055f367e8586653b99a9a9 -conversation.zh.md: 784f52975cbb829d8914ef630aa1041693e1de62 +conversation.md: 415bc46078c61710a7111e3f1951beda736d39e9 +conversation.zh.md: a178b49ea2d55c9f11cc0002da1fea19f8b2d748 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index df1476537b..415bc46078 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -8,7 +8,17 @@ This page defines the data model and the extension path for a business-owned Con ## Data model and ownership -The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` or `{ type: 'chunks', event: ChunkRowEvent }`; both inner events expose `type`, `seq`, `time`, and `data`. `ui-conversation` passes these entries to the assembler without opening a second history stream, converting records, or expanding packed members. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target. +The Session Controller owns the contiguous loaded logical-event window. Each `SessionEventLikeEntry` is either `{ type: 'event', event: SessionEvent }` or `{ type: 'chunks', event: ChunkRowEvent }`; both inner events expose `type`, `seq`, `time`, and `data`. Before assembly, `ConversationPresentationState` folds explicit `conversationOp` replacements, removes entries in hidden raw-event ranges from every current target, and computes loaded current-surface membership for target-owned actions. A replacement append rebuilds the loaded window atomically, so no target publishes an intermediate mix of old and new generations. Ordinary surface replacements without `conversationOp`, including compaction checkpoints, retain their established human-transcript behavior. + +`ui-conversation` passes each retained entry to the assembler without opening a second history stream, converting records, or expanding packed members. One `ConversationNodeAssembler` per Session applies every registered Definition and publishes an independent source for each registered view target. + +```ts type-equiv +/** Current model-surface membership accompanying one Conversation publication. */ +interface ConversationPresentation { + /** Loaded surface-event seqs that remain in the current model context. */ + readonly currentSurfaceSeqs: ReadonlySet +} +``` | Concept | Owner and purpose | |---|---| diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 784f52975c..a178b49ea2 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -8,7 +8,17 @@ Conversation 是 Client `SessionEventLikeEntry` window 与浏览器 view 之间 ## 数据模型与所有权 -Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 都是 `{ type: 'event', event: SessionEvent }` 或 `{ type: 'chunks', event: ChunkRowEvent }`;两种内部 event 都公开 `type`、`seq`、`time` 与 `data`。`ui-conversation` 把这些 entry 直接交给 assembler,不另开 history stream、不转换 record,也不展开 packed member。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。 +Session Controller 拥有连续的已加载逻辑 event window。每个 `SessionEventLikeEntry` 都是 `{ type: 'event', event: SessionEvent }` 或 `{ type: 'chunks', event: ChunkRowEvent }`;两种内部 event 都公开 `type`、`seq`、`time` 与 `data`。组装之前,`ConversationPresentationState` 会折叠显式 `conversationOp` 替换,从每个当前 target 中移除隐藏原始事件区间内的 entry,并为 target 自有动作计算已加载的当前 surface 成员关系。一次替换 append 会原子重建已加载窗口,因此任何 target 都不会发布新旧代次混合的中间状态。没有 `conversationOp` 的普通 surface 替换(包括压缩检查点)维持既有的人类 transcript 行为。 + +`ui-conversation` 把每个被保留的 entry 直接交给 assembler,不另开 history stream、不转换 record,也不展开 packed member。每个 Session 对应一个 `ConversationNodeAssembler`,它应用所有已注册 Definition,并为每个已注册 view target 发布独立 source。 + +```ts type-equiv +/** Current model-surface membership accompanying one Conversation publication. */ +interface ConversationPresentation { + /** Loaded surface-event seqs that remain in the current model context. */ + readonly currentSurfaceSeqs: ReadonlySet +} +``` | 概念 | Owner 与用途 | |---|---| diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 3dac641914..9b4d66c6a2 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.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/core.md -core.md: 170268351454643544d5ab20e3d4b6a8fd8c6c69 -core.zh.md: 048f7ed5f2136bb583c0308635e5cddde2b96785 +core.md: bf54bfb95ac41db665f67093568d340cb7438968 +core.zh.md: c7afa245445dab5ecacc5711f80ecb699f619558 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 1702683514..bf54bfb95a 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -54,7 +54,19 @@ interface AgentHandle { `Agent` is the surface every plugin (UI, hooks, orchestrators) programs against; `ctx.agents.get(id)` returns it, and the [initiator scope](#initiating-agent) carries it. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it. The unified `send` method exposes target and wakeup routing directly; `followup`, `steer`, and `inject` are fixed-preset aliases. -Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) +```ts type-equiv +/** Optional routing and Session placement for one Agent inbox insertion. */ +interface AgentSendOptions { + /** Insert before existing work at the resolved target instead of after it. */ + position?: 'front' | 'back' + /** Non-default placement used when AgentLoop records this exact message. */ + surfaceIntent?: SurfaceIntent + /** Messages recorded immediately after the sent message in the same step. */ + followingMessages?: readonly UserMessage[] +} +``` + +Sources: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts), [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) ```ts type-equiv /** Public live-agent handle; the runtime face augments its live capabilities. */ @@ -110,8 +122,9 @@ interface Agent { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. + * @param options - optional queue position, Session placement, and same-step companions. */ - send(message: UserMessage, target: InboxTarget, wakeup: boolean): void + send(message: UserMessage, target: InboxTarget, wakeup: boolean, options?: AgentSendOptions): void /** * Queue an ordinary follow-up turn and wake the driver. The item becomes the @@ -177,7 +190,19 @@ The inbox is the delivery vocabulary — two ordered pending-message lists the a type InboxTarget = 'next-turn' | 'next-step' ``` -Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists; replacement may change identity and emits the old message as discarded followed by the new message as inserted. Ordinary removals and `clear()` are cancellations. `claim(target)` removes the proposed step batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices without emitting discarded notifications, and the loop separately emits per-message claimed notifications. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. +```ts type-equiv +/** Durable admission metadata attached to one inserted inbox message. */ +interface InboxAdmission { + /** Message identity the metadata follows through queue edits and claim. */ + readonly messageId: MessageId + /** Exact placement applied when AgentLoop records the claimed user message. */ + readonly surfaceIntent?: SurfaceIntent + /** Messages recorded immediately after the identified message in the same step. */ + readonly followingMessages?: readonly UserMessage[] +} +``` + +Every pending occurrence is its `UserMessage`; `MessageId` is the sole identity. `Inbox.append`, `prepend`, `replace`, `remove`, `clear`, `splice`, and `claim` record normalized durable `agent/inbox/spliced` mutations and reject duplicate pending ids. An insertion may carry `InboxAdmission` metadata: non-default Session placement for its primary message and ordered messages that follow it in the same claimed step. `replace(messageId, newMessage)` transfers that metadata to the new identity. Ordinary removals and `clear()` are cancellations. `claim(target)` removes the proposed step batch — all `next-step` input plus, at a turn boundary, one `next-turn` message — through pure deletion splices, expands same-step companions, and emits per-message claimed notifications without treating the claim as a discard. Whole-queue consumers such as UI projections reconstruct `nextTurn` and `nextStep` from the durable splices, while consumers following one message use the exact `agent/inbox/inserted`, `claimed`, and `discarded` notifications. Cancellation: @@ -216,7 +241,7 @@ Pre-step decisions use the same identified `UserMessage` type as durable user-ro Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: +`agent/pre-step` receives one payload carrying the exclusive claimed batch (`messages`), its non-default Session placement keyed by message identity (`surfaceIntents`), the proposed step's coordinates (`turn`, `step`), and the current turn's cancellation `signal`. A listener that may replace the Session surface defers while the map contains a preplanned replacement. The initial proposal runs inside an open turn before any step; a tool continuation may submit an empty claimed batch between steps: It returns a `PreStepDecision`. Reject opens no step. Enter supplies the complete message batch appended after `step/start`; claimed messages omitted by the final decision remain removed, while input inserted after the claim stays pending: @@ -952,16 +977,18 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p * `next()` preserves the current messages. * @param payload.agent - the agent proposing the step. * @param payload.messages - messages removed from the inbox for this step. + * @param payload.surfaceIntents - non-default Session placement retained + * for claimed primary messages; the standard loop always supplies the map. * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; surfaceIntents?: ReadonlyMap; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise ``` -Types: [Scoped](scope.md) · [UserMessage](session.md) +Types: [MessageId](llm-streaming.md) · [Scoped](scope.md) · [SurfaceIntent](session.md) · [UserMessage](session.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 048f7ed5f2..c7afa24544 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -58,7 +58,19 @@ interface AgentHandle { `Agent` 是每个插件(UI、钩子、orchestrator)面向编程的 surface;`ctx.agents.get(id)` 返回它,[发起者作用域](#initiating-agent)携带它。具体实现为 dsh-agent-loop 包内部细节;循环外没有任何组件依赖它。统一的 `send` 方法直接暴露 target 与 wakeup 路由;`followup`、`steer` 与 `inject` 是固定预设的别名方法。 -源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) +```ts type-equiv +/** Optional routing and Session placement for one Agent inbox insertion. */ +interface AgentSendOptions { + /** Insert before existing work at the resolved target instead of after it. */ + position?: 'front' | 'back' + /** Non-default placement used when AgentLoop records this exact message. */ + surfaceIntent?: SurfaceIntent + /** Messages recorded immediately after the sent message in the same step. */ + followingMessages?: readonly UserMessage[] +} +``` + +源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts)、[`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) ```ts type-equiv /** Public live-agent handle; the runtime face augments its live capabilities. */ @@ -114,8 +126,9 @@ interface Agent { * @param message - identified content and the source that supplied it. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. + * @param options - optional queue position, Session placement, and same-step companions. */ - send(message: UserMessage, target: InboxTarget, wakeup: boolean): void + send(message: UserMessage, target: InboxTarget, wakeup: boolean, options?: AgentSendOptions): void /** * Queue an ordinary follow-up turn and wake the driver. The item becomes the @@ -181,7 +194,19 @@ inbox 即投递词汇——agent 以持久投影形式拥有的两条有序待 type InboxTarget = 'next-turn' | 'next-step' ``` -每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。`replace(messageId, newMessage)` 与 `remove(messageId)` 通过 `MessageId` 跨两份列表定位待处理消息;替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——且不发出 discarded 通知;循环另行逐条发出 claimed 通知。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 +```ts type-equiv +/** Durable admission metadata attached to one inserted inbox message. */ +interface InboxAdmission { + /** Message identity the metadata follows through queue edits and claim. */ + readonly messageId: MessageId + /** Exact placement applied when AgentLoop records the claimed user message. */ + readonly surfaceIntent?: SurfaceIntent + /** Messages recorded immediately after the identified message in the same step. */ + readonly followingMessages?: readonly UserMessage[] +} +``` + +每个待处理入队项就是其 `UserMessage`;`MessageId` 是唯一标识。`Inbox.append`、`prepend`、`replace`、`remove`、`clear`、`splice` 与 `claim` 会记录规范化的持久 `agent/inbox/spliced` 变更,并拒绝重复的待处理 id。一次插入可以携带 `InboxAdmission` 元数据:主消息的非默认 Session 放置方式,以及在同一个被领取步骤中紧随其后的有序消息。`replace(messageId, newMessage)` 会把这些元数据转移到新身份。普通删除和 `clear()` 都表示取消。`claim(target)` 通过纯删除 splice 移除拟进入步骤的批次——全部 `next-step` 输入,外加轮次边界上的一条 `next-turn` 消息——展开同一步骤配套消息,并逐条发出 claimed 通知而不把领取视为 discarded。UI 投影等整体队列消费方通过持久 splice 重建 `nextTurn` 与 `nextStep`,而跟踪单条消息的消费方使用精确的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知。 取消: @@ -224,7 +249,7 @@ pre-step 决策使用与持久 user-role 输入相同、带标识的 `UserMessag 源码:[`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) -`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、拟进入步骤的坐标(`turn`、`step`)与当前轮次的取消 `signal`。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: +`agent/pre-step` 接收一个 payload,携带独占的已领取批次(`messages`)、按消息标识索引的非默认 Session 放置方式(`surfaceIntents`)、拟进入步骤的坐标(`turn`、`step`)与当前轮次的取消 `signal`。会替换 Session surface 的监听器在该映射包含预定替换时推迟操作。首次提案在已打开的轮次内、任何步骤开始前运行;工具 continuation 可以在步骤之间提交空的已领取批次: 它返回 `PreStepDecision`。reject 不会打开步骤。enter 提供在 `step/start` 后追加的完整消息批次;最终决策省略的已领取消息保持已删除,而领取后插入的输入仍留待后续处理: @@ -962,16 +987,18 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p * `next()` preserves the current messages. * @param payload.agent - the agent proposing the step. * @param payload.messages - messages removed from the inbox for this step. + * @param payload.surfaceIntents - non-default Session placement retained + * for claimed primary messages; the standard loop always supplies the map. * @param payload.turn - the turn that will own the step. * @param payload.step - the step proposed by the loop. * @param payload.signal - the current turn's cancellation signal. * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. * @mode waterfall */ -'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise +'agent/pre-step'(this: Scoped, payload: { agent: Agent; messages: UserMessage[]; surfaceIntents?: ReadonlyMap; turn: number; step: number; signal: AbortSignal }, next: () => Promise): Promise ``` -Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md) +Types: [MessageId](llm-streaming.zh.md) · [Scoped](scope.zh.md) · [SurfaceIntent](session.zh.md) · [UserMessage](session.zh.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) diff --git a/docs/subsystems/session-query.i18n.yaml b/docs/subsystems/session-query.i18n.yaml index 5371ae0b34..de9d0c1c02 100644 --- a/docs/subsystems/session-query.i18n.yaml +++ b/docs/subsystems/session-query.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/session-query.md -session-query.md: 1cf3dff325db76485a0afca4ca1a882a4ff175b8 -session-query.zh.md: 2b8504dfb9ec8ee25d925135dbc99fa7beaede98 +session-query.md: 6641f2b7320e7230bd4ed152f565a7661bc56d28 +session-query.zh.md: 793fc0f8db96f804ded616310a225da9af7e6e39 diff --git a/docs/subsystems/session-query.md b/docs/subsystems/session-query.md index 1cf3dff325..6641f2b732 100644 --- a/docs/subsystems/session-query.md +++ b/docs/subsystems/session-query.md @@ -8,7 +8,7 @@ Source: [`packages/session-query/session-query/src/types.ts`](../../packages/ses ## Logical records -`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation. +`SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation and then marks every event in an explicit edited-conversation range as `shadowed`. The raw log remains readable through exact operations. ```ts type-equiv /** Whether an event is current model context, replaced context, or raw-log-only. */ @@ -99,7 +99,7 @@ interface SessionEventRecord { type: SessionEventType /** Event timestamp in Unix epoch milliseconds. */ time: number - /** Event placement in the folded session surface. */ + /** Event placement after model-surface and conversation-generation folding. */ surface: SessionEventSurface } ``` @@ -146,7 +146,7 @@ interface SessionEventSearchDocument extends SessionEventRecord { ## Full-text search pages -The combined `ctx.sessionQuery` seam has two full-text scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. +The combined `ctx.sessionQuery` seam has two full-text scopes. `searchSessions()` groups the corpus by strongest matching event; `searchEvents()` searches one session. Requests bind an opaque cursor to the normalized query, metadata filters, and limit. The event text scan is intentionally absent from provider metadata filters. Model-facing tools default both operations to `current` plus `log-only` and do not expose `shadowed`, so edited-away content is excluded without deleting its index records; programmatic callers may still request `shadowed` for diagnostics. ```ts type-equiv /** Provider-owned opaque continuation token returned by session search. */ diff --git a/docs/subsystems/session-query.zh.md b/docs/subsystems/session-query.zh.md index 2b8504dfb9..793fc0f8db 100644 --- a/docs/subsystems/session-query.zh.md +++ b/docs/subsystems/session-query.zh.md @@ -8,7 +8,7 @@ ## 逻辑记录 -`SessionRecord` 由全语料库列表返回。它除了克隆的、优先取自 live 源的 header 外,还单独公开各源的可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与模型历史推导相同的 `foldSurface()` 状态转换。 +`SessionRecord` 由全语料库列表返回。它除了克隆的、优先取自 live 源的 header 外,还单独公开各源的可用性。`SessionEventRecord` 是轻量的原始日志投影;分类使用与模型历史推导相同的 `foldSurface()` 状态转换,随后把显式编辑对话区间内的每个事件标记为 `shadowed`。原始日志仍可通过精确操作读取。 ```ts type-equiv /** Whether an event is current model context, replaced context, or raw-log-only. */ @@ -99,7 +99,7 @@ interface SessionEventRecord { type: SessionEventType /** Event timestamp in Unix epoch milliseconds. */ time: number - /** Event placement in the folded session surface. */ + /** Event placement after model-surface and conversation-generation folding. */ surface: SessionEventSurface } ``` @@ -146,7 +146,7 @@ interface SessionEventSearchDocument extends SessionEventRecord { ## 全文搜索结果页 -整合后的 `ctx.sessionQuery` seam 提供两个全文搜索范围。`searchSessions()` 按匹配度最强的事件对语料库分组;`searchEvents()` 搜索单个会话。请求将不透明游标与规范化后的查询、元数据过滤器和结果数量上限绑定。提供方的元数据过滤器有意不包含事件文本扫描。 +整合后的 `ctx.sessionQuery` seam 提供两个全文搜索范围。`searchSessions()` 按匹配度最强的事件对语料库分组;`searchEvents()` 搜索单个会话。请求将不透明游标与规范化后的查询、元数据过滤器和结果数量上限绑定。提供方的元数据过滤器有意不包含事件文本扫描。面向模型的工具默认让两个操作只查询 `current` 与 `log-only`,且不公开 `shadowed`,因此被编辑替换的内容无需删除索引记录也会被排除;程序化调用方仍可显式请求 `shadowed` 进行诊断。 ```ts type-equiv /** Provider-owned opaque continuation token returned by session search. */ diff --git a/docs/subsystems/session-title.i18n.yaml b/docs/subsystems/session-title.i18n.yaml index 63292c20f0..22b821af72 100644 --- a/docs/subsystems/session-title.i18n.yaml +++ b/docs/subsystems/session-title.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/session-title.md -session-title.md: 20ed32b4a08127e7193433673d22f0dea3a90c46 -session-title.zh.md: c01d5d4bf177169069bbe0a39ef533ada0f7c36a +session-title.md: c346fef215db8d98c12d601a1bab0690df455429 +session-title.zh.md: a42a9e762686225a0007c50eab771faacea0d4ce diff --git a/docs/subsystems/session-title.md b/docs/subsystems/session-title.md index 20ed32b4a0..c346fef215 100644 --- a/docs/subsystems/session-title.md +++ b/docs/subsystems/session-title.md @@ -86,7 +86,7 @@ interface SessionTitleLlmRequestEventData { ## Provider input and output -The service snapshots eligible messages through one revision. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind. +The service snapshots eligible messages through one revision. Only append-origin human messages schedule automatic work, so a same-session edit does not regenerate the current title; a later ordinary prompt builds its provider request from the then-current conversation generation and omits edited-away prompts. A provider returns only seqs from that request; service-owned acceptance verifies ordering, normalizes the title, enforces the byte limit, and appends the title with its source-message seqs and source kind. ```ts type-equiv /** One eligible human text message exposed to title providers. */ @@ -108,7 +108,7 @@ type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts' interface SessionTitleProviderRequest { /** Live session being titled. */ readonly session: Session - /** All eligible human messages through this generation revision. */ + /** Current-generation eligible human messages through this title revision. */ readonly messages: readonly SessionTitleUserMessage[] /** Exact current logged main-request route, when one has been recorded. */ readonly route?: SessionTitleModelProvenance diff --git a/docs/subsystems/session-title.zh.md b/docs/subsystems/session-title.zh.md index c01d5d4bf1..a42a9e7626 100644 --- a/docs/subsystems/session-title.zh.md +++ b/docs/subsystems/session-title.zh.md @@ -86,7 +86,7 @@ interface SessionTitleLlmRequestEventData { ## 提供方输入与输出 -服务会对截至某一修订的合格消息创建快照。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。 +服务会对截至某一修订的合格消息创建快照。只有追加来源的人工消息会安排自动工作,因此同会话编辑不会重新生成当前标题;后续普通提示词会从当时的当前对话代次构建提供方请求,并省略被编辑隐藏的提示词。提供方返回的 seq 仅可来自该请求;由服务负责的接纳流程会验证顺序、规范化标题、强制执行字节上限,并追加标题及其来源消息 seq 和来源类型。 ```ts type-equiv /** One eligible human text message exposed to title providers. */ @@ -108,7 +108,7 @@ type SessionTitleAutomaticMode = 'first-prompt' | 'all-prompts' interface SessionTitleProviderRequest { /** Live session being titled. */ readonly session: Session - /** All eligible human messages through this generation revision. */ + /** Current-generation eligible human messages through this title revision. */ readonly messages: readonly SessionTitleUserMessage[] /** Exact current logged main-request route, when one has been recorded. */ readonly route?: SessionTitleModelProvenance diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index f9653cecea..4612dc18bb 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.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/session.md -session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31 -session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd +session.md: 11ad6a4d823322fe6fcbbe394be80b41dde5d0dd +session.zh.md: bc21494320fcacdf6e4d2180a67c36ecd938d444 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 48a80ccb92..11ad6a4d82 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -208,7 +208,8 @@ type OptionalSessionSeq = SessionSeq | null * A proper discriminated union over `type` (not independent `type`/`data` * unions), so `switch (event.type)` narrows `event.data` without casts. * - * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp} + * fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry @@ -246,6 +247,8 @@ type SessionEvent = { sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp + /** Raw event range this message replaces in current conversation projections. */ + conversationOp?: ConversationOp } : object) }[T] ``` @@ -295,6 +298,48 @@ type SurfaceOp = `'append'` is the normal tail-append path. `replace` shadows surface entries from `start` through `end` inclusive (both must be valid surface seqs; `start === end` replaces a single entry) and inserts the new event in their place. +### `ConversationOp` — current user-facing generation replacement + +```ts type-equiv +/** + * How one message starts a new user-facing conversation generation. + * + * The inclusive raw-event range remains in the append-only log but is omitted + * from current conversation projections. Unlike {@link SurfaceOp}, this range + * covers every event family rendered by Chat, Trajectory, search, and + * transcript exporters rather than only model-message surface nodes. + */ +type ConversationOp = { op: 'replace'; start: SessionSeq; end: SessionSeq } +``` + +Only a replacement `user/message` may carry `conversationOp`. Its inclusive range must precede the replacement event. `foldConversation()` merges overlapping or adjacent ranges, and membership checks use binary search over that normalized result. + +```ts type-equiv +/** One committed user-facing replacement and the event that committed it. */ +interface ConversationReplacement extends ConversationOp { + /** Seq of the replacement `user/message`. */ + readonly seq: SessionSeq +} +``` + +```ts type-equiv +/** A merged inclusive interval hidden from the current conversation. */ +interface ConversationHiddenRange { + readonly start: SessionSeq + readonly end: SessionSeq +} +``` + +```ts type-equiv +/** Complete result of folding conversation replacements from one event window. */ +interface ConversationFoldResult { + /** Replacement operations in event order. */ + readonly replacements: readonly ConversationReplacement[] + /** Sorted, non-overlapping raw-event ranges hidden by those replacements. */ + readonly hiddenRanges: readonly ConversationHiddenRange[] +} +``` + ### `SurfaceIntent` — the parameter to `session.append()` ```ts type-equiv @@ -311,10 +356,12 @@ interface SurfaceIntent { * Other surface events require a non-empty set when this field is present. */ sourceEventSeqs?: SessionSeq[] + /** Optional user-facing conversation replacement committed with this message. */ + conversationOp?: ConversationOp } ``` -Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. A human-facing transcript is the other projection and reads the log's append-origin events instead, because the surface deliberately shadows the ranges a replacement summarizes (`isAppendSurfaceEvent` in [dsh-session](../../packages/core/session/README.md)). Non-surface types reject it at compile time. +Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. Human-facing projections normally read append-origin events so compaction remains visible as history; an explicit `conversationOp` is the narrow exception that replaces one user-visible generation without deleting it from the log. Non-surface types reject surface metadata at compile time. Only `assistant/message` may carry a present empty `sourceEventSeqs`; when the field is absent, the event does not record which earlier events produced the message, and the provider may still have emitted chunks. @@ -488,10 +535,11 @@ declare class Session { * @param data - The event payload; must be JSON-serializable. * @param opts - Surface metadata: `surfaceOp` controls how the event enters * the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier - * events this one derives from. REQUIRED for - * {@link SurfaceEventType} events (every message-producing event must - * declare how it joins the surface, the sole source of derived model - * history) and + * events this one derives from; `conversationOp` lets a replacement user + * message hide one earlier raw-event range from current conversation + * views. REQUIRED for {@link SurfaceEventType} events (every + * message-producing event must declare how it joins the surface, the sole + * source of derived model history) and * rejected by the compiler for non-surface types like `turn/start` or * `assistant/chunk`. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of @@ -757,6 +805,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Replace the latest current turn-opening user message and rerun from that point. + * @param request - target message, optimistic revision, and replacement text. + * @param signal - caller cancellation before the replacement is admitted. + * @returns acknowledgement after the replacement message commits. + */ +@Remote('edit') edit(request: SessionEditRequest, signal: AbortSignal): Promise + /** * Read one image proven reachable from the addressed Session log. * @param request - Session and attachment identities used for authorization. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index df5cfd631a..bc21494320 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -208,7 +208,8 @@ type OptionalSessionSeq = SessionSeq | null * A proper discriminated union over `type` (not independent `type`/`data` * unions), so `switch (event.type)` narrows `event.data` without casts. * - * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * The {@link sourceEventSeqs}, {@link surfaceOp}, and {@link conversationOp} + * fields are conditional: * they only exist on {@link SurfaceEventType} variants (`user/message`, * `assistant/message`, `tool/result`). * Non-surface events (boundary markers, chunks, usage, errors) never carry @@ -246,6 +247,8 @@ type SessionEvent = { sourceEventSeqs?: SessionSeq[] /** How this event entered the surface; absent for non-surface events. */ surfaceOp?: SurfaceOp + /** Raw event range this message replaces in current conversation projections. */ + conversationOp?: ConversationOp } : object) }[T] ``` @@ -297,6 +300,48 @@ type SurfaceOp = `'append'` 是常规的尾部追加路径。`replace` 会遮蔽从 `start` 到 `end`(含两端)的 surface 条目(两者都必须是有效的 surface seq;`start === end` 时仅替换单个条目),并在原位置插入新事件。 +### `ConversationOp`:当前用户可见对话代次替换 + +```ts type-equiv +/** + * How one message starts a new user-facing conversation generation. + * + * The inclusive raw-event range remains in the append-only log but is omitted + * from current conversation projections. Unlike {@link SurfaceOp}, this range + * covers every event family rendered by Chat, Trajectory, search, and + * transcript exporters rather than only model-message surface nodes. + */ +type ConversationOp = { op: 'replace'; start: SessionSeq; end: SessionSeq } +``` + +只有替换型 `user/message` 可以携带 `conversationOp`,且其闭区间必须位于替换事件之前。`foldConversation()` 会合并重叠或相邻区间,成员关系检查则在规范化结果上使用二分查找。 + +```ts type-equiv +/** One committed user-facing replacement and the event that committed it. */ +interface ConversationReplacement extends ConversationOp { + /** Seq of the replacement `user/message`. */ + readonly seq: SessionSeq +} +``` + +```ts type-equiv +/** A merged inclusive interval hidden from the current conversation. */ +interface ConversationHiddenRange { + readonly start: SessionSeq + readonly end: SessionSeq +} +``` + +```ts type-equiv +/** Complete result of folding conversation replacements from one event window. */ +interface ConversationFoldResult { + /** Replacement operations in event order. */ + readonly replacements: readonly ConversationReplacement[] + /** Sorted, non-overlapping raw-event ranges hidden by those replacements. */ + readonly hiddenRanges: readonly ConversationHiddenRange[] +} +``` + ### `SurfaceIntent`:`session.append()` 的参数 ```ts type-equiv @@ -313,10 +358,12 @@ interface SurfaceIntent { * Other surface events require a non-empty set when this field is present. */ sourceEventSeqs?: SessionSeq[] + /** Optional user-facing conversation replacement committed with this message. */ + conversationOp?: ConversationOp } ``` -对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生模型历史的唯一来源)。面向人类的 transcript(文本记录)是另一个投影,读取的是日志中追加来源的事件,因为 surface 会有意遮蔽替换所概括的范围(见 [dsh-session](../../packages/core/session/README.zh.md) 的 `isAppendSurfaceEvent`)。非 surface 类型在编译期拒绝此参数。 +对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生模型历史的唯一来源)。面向人类的投影通常读取追加来源事件,使压缩后仍保留历史;显式 `conversationOp` 是一个窄例外,它替换一代用户可见对话,但不从日志删除原事件。非 surface 类型在编译期拒绝 surface 元数据。 只有 `assistant/message` 可以携带存在但为空的 `sourceEventSeqs`;字段不存在时,该事件没有记录这条消息由哪些早期事件产生,但提供方仍可能发出过分片。 @@ -490,10 +537,11 @@ declare class Session { * @param data - The event payload; must be JSON-serializable. * @param opts - Surface metadata: `surfaceOp` controls how the event enters * the ordered surface; `sourceEventSeqs` lists the seq numbers of earlier - * events this one derives from. REQUIRED for - * {@link SurfaceEventType} events (every message-producing event must - * declare how it joins the surface, the sole source of derived model - * history) and + * events this one derives from; `conversationOp` lets a replacement user + * message hide one earlier raw-event range from current conversation + * views. REQUIRED for {@link SurfaceEventType} events (every + * message-producing event must declare how it joins the surface, the sole + * source of derived model history) and * rejected by the compiler for non-surface types like `turn/start` or * `assistant/chunk`. * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of @@ -761,6 +809,14 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise +/** + * Replace the latest current turn-opening user message and rerun from that point. + * @param request - target message, optimistic revision, and replacement text. + * @param signal - caller cancellation before the replacement is admitted. + * @returns acknowledgement after the replacement message commits. + */ +@Remote('edit') edit(request: SessionEditRequest, signal: AbortSignal): Promise + /** * Read one image proven reachable from the addressed Session log. * @param request - Session and attachment identities used for authorization. diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index a70f3d6e35..a4e9d71319 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-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/tool-catalog.md -tool-catalog.md: 416585106de28296a3105fafbbe6e4edc0d0f16b -tool-catalog.zh.md: 89d2e467863c09b78805fd9db8ec785ad7936a9d +tool-catalog.md: 9e38a9503ccfb91a18f0e27184138016b747d56d +tool-catalog.zh.md: c323ec2c8ee333715ea8d78bb1626e740d060afc diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 416585106d..9e38a9503c 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -1375,12 +1375,11 @@ Search prior events in one authorized session; the current session excludes the }, "surfaces": { "type": "array", - "description": "Event surfaces to include.", + "description": "Current model-surface or active log-only events to include.", "items": { "type": "string", "enum": [ "current", - "shadowed", "log-only" ] } @@ -1493,12 +1492,11 @@ Search prior sessions in the caller workspace and return the strongest matching }, "event_surfaces": { "type": "array", - "description": "Event surfaces to include.", + "description": "Current model-surface or active log-only events to include.", "items": { "type": "string", "enum": [ "current", - "shadowed", "log-only" ] } diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 89d2e46786..c323ec2c8e 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -1381,12 +1381,11 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, }, "surfaces": { "type": "array", - "description": "Event surfaces to include.", + "description": "Current model-surface or active log-only events to include.", "items": { "type": "string", "enum": [ "current", - "shadowed", "log-only" ] } @@ -1499,12 +1498,11 @@ lsp 工具将提供方选择和语言服务器子进程置于 ctx.lsp 之后, }, "event_surfaces": { "type": "array", - "description": "Event surfaces to include.", + "description": "Current model-surface or active log-only events to include.", "items": { "type": "string", "enum": [ "current", - "shadowed", "log-only" ] } diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 4ee411fe0e..f01e4fd467 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/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/api/session-controller/README.md -README.md: cf78a247e27ec37ff51b10bad3f925348b9e01a2 -README.zh.md: 06d61851eec1ffdc99ed9348fac8b72cbc745b50 +README.md: 1747833eae24d0584c9ce309317e621db106482c +README.zh.md: 8c6348aac1bec69492c67f288eba4ee3a9921ae0 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index cf78a247e2..1747833eae 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -25,11 +25,13 @@ English | [中文](README.zh.md) History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. -Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. +Each endpoint states its activation policy. List, search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Queue mutation and cancellation require live state; model, rename, prompt, edit, and file-reference operations may resolve or resume an ordinary Session. Create and fork are the only operations that create a new Agent directly. The skill catalog instead uses a live Agent when present or the recorded preset's standing scope when cold, so listing never starts an Agent. + +`session.edit` accepts only the latest human message when it is a current-surface, turn-opening prompt in an ordinary Session. The request carries that latest observed human-message seq as an optimistic revision; the Host revalidates it while holding idle maintenance, cancels a running turn with its inbox preserved, and places the edited rerun ahead of existing Queue work. A concurrent maintenance reservation reports `session/agent-busy`, a changed target reports `session/edit-stale`, and unexpected admission faults report `gateway/internal`. The replacement keeps non-text content, retained session-reference context, and the current model selection. Its new `user/message` commits both a model-surface replacement and a user-facing conversation replacement before the RPC acknowledges success. Edit does not create a Session, regenerate its title, or roll back external side effects from the hidden turns. The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. -The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. +The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. `SessionSnapshot.pendingEdit` applies the same handoff rule to one optimistic replacement: Chat hides the selected suffix immediately, and the echo retires only after the replacement event is renderable or the operation fails. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. ----- @@ -49,11 +51,11 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ## Model Experience -None, as invoked Agent commands own any model-visible effect. +None, as the controller delegates the replacement request and its model-visible history to the Agent and Session surface. #### KV Cache effect -No direct effect; model requests remain owned by the Agent and LLM packages. +No direct effect; the delegated surface replacement starts a new request series and preserves reuse only through the unchanged prefix before the edited turn. ## Known Limitations and Deferred Work diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 06d61851ee..8c6348aac1 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -25,11 +25,13 @@ kind: "package-reference" 历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 -每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。queue 变更与取消要求 live 状态;模型、重命名、prompt、edit 和文件引用操作可以解析或恢复普通 Session。只有 create 与 fork 会直接创建新 Agent。skill 目录则优先使用已有 live Agent,否则使用所记录 preset 的常驻 scope,因此列表查询绝不会启动 Agent。 + +`session.edit` 只接受普通 Session 中最新的人工消息,且它必须是仍位于当前 surface 的轮次开场提示词。请求把该最新已观测人工消息的 seq 作为乐观 revision;Host 在持有 idle maintenance 时重新校验,使用保留 inbox 的方式取消运行中轮次,并把编辑后的重跑放到现有 Queue 工作之前。并发 maintenance 占用报告 `session/agent-busy`,目标发生变化报告 `session/edit-stale`,意外准入故障报告 `gateway/internal`。替换会保留非文本内容、已有的会话引用上下文与当前模型选择。新的 `user/message` 同时提交模型 surface 替换和用户可见对话替换,RPC 随后才确认成功。Edit 不会创建新 Session、重新生成标题,也不会回滚被隐藏轮次已经产生的外部副作用。 Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 -Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 +Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。`SessionSnapshot.pendingEdit` 对一次乐观替换应用相同的交接规则:Chat 会立刻隐藏选中消息起的后缀,只有替换事件已可渲染或操作失败后,回显才会退休。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 ----- @@ -49,11 +51,11 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 ## 模型体验 -无,因为被调用的 Agent 命令拥有任何模型可见效果。 +无,因为 controller 会把替换请求及其模型可见历史委托给 Agent 与 Session surface。 #### KV Cache 影响 -无直接影响;模型请求仍由 Agent 和 LLM 包拥有。 +无直接影响;被委托的 surface 替换会开启新的请求序列,只有编辑轮次之前未变化的前缀仍可能复用。 ## 已知限制与延期工作 diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts index 43c0f2dfd6..24d0164c9a 100644 --- a/packages/api/session-controller/src/client/contract/session.ts +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -86,6 +86,20 @@ export interface ISession { signal?: AbortSignal, requestId?: SessionRequestId, ): Promise> + /** + * Replace the latest current turn-opening human message and rerun from it. + * @param messageSeq - selected durable user-message event. + * @param expectedLastUserSeq - optimistic conversation revision captured by the editor. + * @param text - replacement text; retained non-text blocks remain Host-owned. + * @param signal - optional caller cancellation before admission. + * @returns the committed replacement event seq, or a business error. + */ + edit( + messageSeq: number, + expectedLastUserSeq: number, + text: string, + signal?: AbortSignal, + ): Promise> /** * Resolve one durable image referenced by this session. * @param attachmentId - opaque id found in the folded session log. diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts index ff08a2fd06..de8da19568 100644 --- a/packages/api/session-controller/src/client/contract/snapshot.ts +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -52,12 +52,26 @@ export interface PendingSubmission { readonly images: readonly PendingSubmissionImage[] } +/** One local same-session edit awaiting its durable replacement message. */ +export interface PendingEdit { + /** RPC identity echoed by the replacement `user/message`. */ + readonly requestId: SessionRequestId + /** Existing user-message event being replaced. */ + readonly targetSeq: number + /** Latest human message observed when edit mode opened. */ + readonly expectedLastUserSeq: number + /** Replacement text submitted by the inline editor. */ + readonly text: string + /** Client wall-clock ms used by the optimistic replacement bubble. */ + readonly time: number +} + /** History-open lifecycle of a Session event window. */ export type OpenState = 'cold' | 'loading' | 'open' | 'error' /** Send/stop failure surfaced by Session consumers. */ export interface PromptError { - readonly op: 'send' | 'stop' + readonly op: 'send' | 'stop' | 'edit' readonly error: RemoteFailure } @@ -67,6 +81,8 @@ export interface SessionSnapshot { readonly queue: readonly QueuedMessage[] /** Local prompt-submission echoes not yet observed as durable events or queue occurrences. */ readonly pendingSubmissions: readonly PendingSubmission[] + /** Same-session edit waiting for its replacement user message. */ + readonly pendingEdit: PendingEdit | null readonly running: boolean readonly subagent: { readonly address: SubagentAddress diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts index d9d58d41e2..d7ae469ee4 100644 --- a/packages/api/session-controller/src/client/sessions/session.ts +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -21,7 +21,7 @@ import type { BeginSubmissionInput, PendingSubmissionRetirement, SessionFace, SubmissionHandle, } from '../contract/session.ts' import type { - OpenState, PendingSubmission, PromptError, SessionSnapshot, + OpenState, PendingEdit, PendingSubmission, PromptError, SessionSnapshot, } from '../contract/snapshot.ts' import { MutableSessionEventSource } from '../contract/events.ts' import type { @@ -113,6 +113,7 @@ export class Session implements SessionFace { private lastAgentError: string | null = null /** Local submission echoes, insertion-ordered (see SessionSnapshot.pendingSubmissions). */ private pendingSubmissions: readonly PendingSubmission[] = [] + private pendingEdit: PendingEdit | null = null /** Per-echo settlement state; `retiring` latches the first observation so a * queue frame and its durable event cannot both retire one echo. */ private readonly submissionSettlements = new Map> { + const requestId = randomUUID() as SessionRequestId + this.pendingEdit = { requestId, targetSeq: messageSeq, expectedLastUserSeq, text, time: Date.now() } + this.promptError = null + this.lastAgentError = null + this.notifier.markDirty() + let result: RemoteResult<{ accepted: true; messageSeq: number }> + try { + result = await this.remote.session.edit({ + requestId, + sessionId: this.sessionId, + messageSeq, + expectedLastUserSeq, + text, + clientTimeZone: resolvedClientTimeZone(), + }, signal) + } catch (error: unknown) { + const cleared = this.clearPendingEdit(requestId) + if (isRemoteFailure(error)) { + this.promptError = { op: 'edit', error } + this.notifier.markDirty() + return { ok: false, error } + } + if (cleared) this.notifier.markDirty() + throw error + } + if (!result.ok && this.clearPendingEdit(requestId)) { + this.promptError = { op: 'edit', error: result.error } + this.notifier.markDirty() + } + return result + } + /** * Resolve one image referenced by this session into browser-consumable bytes. * @param attachmentId - opaque id found in the folded session log. @@ -582,6 +622,12 @@ export class Session implements SessionFace { // ---- Private ---- + private clearPendingEdit(requestId: SessionRequestId): boolean { + if (this.pendingEdit === null || this.pendingEdit.requestId !== requestId) return false + this.pendingEdit = null + return true + } + /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */ private async doOpen(generation: number): Promise { this.openState = 'loading' @@ -664,6 +710,17 @@ export class Session implements SessionFace { /** Retire the matching echo when a durable browser-prompt `user/message` becomes visible. */ private observeSubmissionEvent(event: { readonly type: string; readonly data?: unknown }): void { + if (event.type === 'user/message' && this.pendingEdit !== null) { + const source = (event.data as { readonly source?: { readonly rpcId?: unknown } } | undefined)?.source + if (source?.rpcId === this.pendingEdit.requestId) { + const requestId = this.pendingEdit.requestId + scheduleFrame(() => { + if (this.pendingEdit?.requestId !== requestId) return + this.pendingEdit = null + this.notifier.markDirty() + }) + } + } if (this.submissionSettlements.size === 0 || event.type !== 'user/message') return // Structural read: window entries may be compact history records, so the // fields are narrowed rather than trusted (same posture as Conversation @@ -737,6 +794,7 @@ export class Session implements SessionFace { sessionId: this.sessionId, queue: this.queueMirror.snapshot(), pendingSubmissions: this.pendingSubmissions, + pendingEdit: this.pendingEdit, running: this.running, subagent: this.address === undefined ? null diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 0e8015f79c..ca175402a1 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -9,9 +9,9 @@ import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { ReasoningEffortId, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' -import type { MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageId, MessageSource } from '@deepseek-ai/dsh-llm' import { SessionLogOffset, SessionSeq } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time' @@ -34,6 +34,8 @@ import type { SessionCancelValue, SessionCreateRequest, SessionCreateValue, + SessionEditRequest, + SessionEditValue, SessionForkRequest, SessionForkValue, SessionPromptRequest, @@ -52,6 +54,35 @@ interface SessionReadState { readonly events: readonly SessionEvent[] } +/** One admitted edit and the exact replacement event it is waiting to commit. */ +interface EditAdmission { + readonly committed: Promise> +} + +/** Expected loss of the exact edit admission after it was queued. */ +class EditAdmissionStale extends Error {} + +/** Preserve the Agent maintenance refusal as a retryable Session-domain failure. */ +function editMaintenanceBusy(agent: Agent, error: unknown): RemoteError<'session/agent-busy'> { + return new RemoteError( + 'session/agent-busy', + `session "${agent.id}" is busy with another maintenance operation`, + { reason: String(error) }, + { cause: error }, + ) +} + +/** Validated replacement ranges and original content for one edit request. */ +interface EditTarget { + readonly event: SessionEvent<'user/message'> + readonly turnStartSeq: SessionSeq + readonly rawEndSeq: SessionSeq + readonly surfaceStart: SessionSeq + readonly surfaceEnd: SessionSeq + readonly shadowedSurfaceSeqs: SessionSeq[] + readonly preservedContexts: readonly SessionEvent<'user/message'>[] +} + /** Implements Session business commands delegated by the Session Controller Remote service. */ export class SessionCommandController { /** @@ -340,6 +371,93 @@ export class SessionCommandController { return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() } + /** + * Replace the latest current turn-opening human message and prioritize its rerun + * ahead of already queued turns. + * @param request - target message, optimistic revision, and replacement text. + * @param signal - caller cancellation observed before inbox admission. + * @returns acknowledgement after the replacement message enters the log. + */ + async edit(request: SessionEditRequest, signal: AbortSignal): Promise { + validateEditRequest(request) + signal.throwIfAborted() + const agent = await this.resolveAgent(request.sessionId) + const initialTarget = resolveEditTarget(agent.session, request) + replaceMessageText(initialTarget.event.data.content, request.text) + const hasImage = initialTarget.event.data.content.some(block => block.type === 'image') + const clientTimeZone = request.clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(request.clientTimeZone) + if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { + throw new RemoteError( + 'session/invalid-time-zone', + 'clientTimeZone must be UTC or a valid IANA Area/Location name', + { value: request.clientTimeZone }, + ) + } + const admit = async (): Promise => { + const selection = this.agents.selectionFor(agent).current + if (!routeServed(this.ctx, selection.provider)) { + throw new RemoteError( + 'session/model-unavailable', + `no adapter serves provider "${selection.provider}"; select a model for this session`, + { provider: selection.provider, model: selection.model }, + ) + } + if (hasImage) { + const model = await this.ctx.llm.resolveModelInfo(selection.provider, selection.model) + if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { + throw new RemoteError( + 'session/attachment-invalid', + `Model "${selection.model}" does not support image input.`, + { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + ) + } + } + let committed: SessionEvent<'user/message'> + try { + let admission: EditAdmission + if (agent.status === 'running') { + admission = await this.reserveMaintenanceAfterCancel(agent, signal, () => { + signal.throwIfAborted() + return Promise.resolve(this.admitEdit(agent, request, clientTimeZone)) + }) + } else { + let maintenance: Promise + try { + maintenance = agent.runMaintenance((maintenanceSignal) => { + signal.throwIfAborted() + maintenanceSignal.throwIfAborted() + return Promise.resolve(this.admitEdit(agent, request, clientTimeZone)) + }) + } catch (error: unknown) { + throw editMaintenanceBusy(agent, error) + } + admission = await maintenance + } + committed = await admission.committed + } catch (error: unknown) { + if (remoteErrorOf(error) !== undefined || signal.aborted) throw error + if (error instanceof EditAdmissionStale) { + throw new RemoteError( + 'session/edit-stale', + `session "${request.sessionId}" changed before the edit could be admitted`, + { sessionId: request.sessionId, messageSeq: request.messageSeq }, + { cause: error }, + ) + } + throw new RemoteError( + 'gateway/internal', + `session "${request.sessionId}" edit admission failed: ${String(error)}`, + {}, + { cause: error }, + ) + } + return { accepted: true, messageSeq: committed.seq } + } + return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() + } + /** * Read one durable image after proving the Session log references it. * @param request - Session and attachment identities used for authorization. @@ -484,6 +602,92 @@ export class SessionCommandController { return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } } + /** Cancel a running turn and synchronously reserve maintenance at its idle transition. */ + private reserveMaintenanceAfterCancel( + agent: Agent, + signal: AbortSignal, + operation: (signal: AbortSignal) => Promise, + ): Promise { + signal.throwIfAborted() + return new Promise((resolve, reject) => { + let settled = false + const finish = (result: Promise): void => { + /* v8 ignore next -- every finishing path removes the other listeners before yielding. */ + if (settled) return + settled = true + dispose() + signal.removeEventListener('abort', abort) + result.then(resolve, reject) + } + const abort = (): void => { + const reason = signal.reason instanceof Error + ? signal.reason + : new Error('edit request aborted', { cause: signal.reason }) + finish(Promise.reject(reason)) + } + const dispose = this.ctx.on('agent/status', ({ agent: subject, status }) => { + if (subject !== agent || status !== 'idle') return + try { + finish(agent.runMaintenance(operation)) + } catch (error: unknown) { + finish(Promise.reject(editMaintenanceBusy(agent, error))) + } + }, { global: true }) + signal.addEventListener('abort', abort, { once: true }) + try { + agent.cancel({ kind: 'user' }, { keepInbox: true }) + } catch (error: unknown) { + finish(Promise.reject(error instanceof Error ? error : new Error('edit cancellation failed', { cause: error }))) + } + }) + } + + /** Validate and enqueue one edit while maintenance owns the idle Agent. */ + private admitEdit( + agent: Agent, + request: SessionEditRequest, + clientTimeZone: string | undefined, + ): EditAdmission { + const target = resolveEditTarget(agent.session, request) + const source: MessageSource = { + kind: 'user', + rpcId: request.requestId, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + } + const message = createUserMessage({ + content: replaceMessageText(target.event.data.content, request.text), + source, + }) + const followingMessages = target.preservedContexts.map(context => createUserMessage({ + content: [...context.data.content], + source: context.data.source, + })) + const waiter = waitForEditedMessage(this.ctx, agent, message.id) + try { + agent.send(message, 'next-turn', true, { + position: 'front', + followingMessages, + surfaceIntent: { + surfaceOp: { + op: 'replace', + start: target.surfaceStart, + end: target.surfaceEnd, + }, + sourceEventSeqs: target.shadowedSurfaceSeqs, + conversationOp: { + op: 'replace', + start: target.turnStartSeq, + end: target.rawEndSeq, + }, + }, + }) + } catch (error: unknown) { + waiter.dispose() + throw error + } + return { committed: waiter.committed } + } + private async forkWorkspace(source: SessionHeader): Promise { const workspaces = this.ctx.workspaceRegistry.list() const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) @@ -497,6 +701,166 @@ export class SessionCommandController { } } +/** Validate scalar fields before resolving or interrupting an Agent. */ +function validateEditRequest(request: SessionEditRequest): void { + if (!Number.isSafeInteger(request.messageSeq) || request.messageSeq < 0) { + throw new RemoteError('gateway/bad-request', 'messageSeq must be a non-negative safe integer', {}) + } + if (!Number.isSafeInteger(request.expectedLastUserSeq) || request.expectedLastUserSeq < 0) { + throw new RemoteError('gateway/bad-request', 'expectedLastUserSeq must be a non-negative safe integer', {}) + } +} + +/** Resolve the exact current turn and model-surface suffix replaced by an edit. */ +function resolveEditTarget(session: Session, request: SessionEditRequest): EditTarget { + const events = session.snapshotEvents() + const latestUser = events.findLast(event => + event.type === 'user/message' && event.data.source.kind === 'user') + if (latestUser?.seq !== request.expectedLastUserSeq) { + throw new RemoteError( + 'session/edit-stale', + `session "${request.sessionId}" changed after message ${String(request.messageSeq)} entered edit mode`, + { sessionId: request.sessionId, messageSeq: request.messageSeq }, + ) + } + const target = events[request.messageSeq] + if (target?.type !== 'user/message' + || target.data.source.kind !== 'user' + || !target.data.content.some(block => block.type === 'text')) { + throw editUnavailable(request, 'the selected event is not an editable human message') + } + if (target.seq !== latestUser.seq) { + throw editUnavailable(request, 'only the latest human message can be edited') + } + const currentSurface = session.surface.nodes + if (!currentSurface.includes(target.seq)) { + throw editUnavailable(request, 'the selected message is not in the current model context') + } + const turnStart = events.slice(0, target.seq + 1).findLast(event => event.type === 'turn/start') + if (turnStart?.type !== 'turn/start') { + throw editUnavailable(request, 'the selected message has no owning turn') + } + const firstStepStart = events.find(event => + event.seq > turnStart.seq + && event.type === 'step/start' + && event.data.turn === turnStart.data.turn) + if (firstStepStart?.type !== 'step/start' || firstStepStart.data.step !== 1) { + throw editUnavailable(request, 'the selected message has no first step') + } + const stepEndIndex = events.findIndex(event => + event.seq > firstStepStart.seq + && event.type === 'step/end' + && event.data.turn === turnStart.data.turn + && event.data.step === firstStepStart.data.step) + const firstStepEnd = stepEndIndex === -1 ? events.length : stepEndIndex + const openingRangeStart = target.seq < firstStepStart.seq ? turnStart.seq + 1 : firstStepStart.seq + 1 + const openingRangeEnd = target.seq < firstStepStart.seq ? firstStepStart.seq : firstStepEnd + const openingHuman = events.slice(openingRangeStart, openingRangeEnd).findLast(event => + event.type === 'user/message' && event.data.source.kind === 'user') + if (openingHuman?.seq !== target.seq) { + throw editUnavailable(request, 'the selected message is steering rather than the turn-opening prompt') + } + const firstSurfaceIndex = currentSurface.findIndex(seq => seq > turnStart.seq && seq <= target.seq) + const surfaceEnd = currentSurface.at(-1) + /* v8 ignore next -- target membership above guarantees one same-turn surface node and a non-empty surface. */ + if (firstSurfaceIndex < 0 || surfaceEnd === undefined) { + throw editUnavailable(request, 'the selected turn has no current model context') + } + const shadowedSurfaceSeqs = currentSurface.slice(firstSurfaceIndex) + return { + event: target, + turnStartSeq: turnStart.seq, + // The indexed target came from this snapshot, so the final event exists. + rawEndSeq: (events.at(-1) as SessionEvent).seq, + surfaceStart: shadowedSurfaceSeqs[0] as SessionSeq, + surfaceEnd, + shadowedSurfaceSeqs: [...shadowedSurfaceSeqs], + preservedContexts: events.slice(target.seq + 1, firstStepEnd).filter(isSessionReferenceContext), + } +} + +/** Whether one user-role context is the durable recall paired with a prompt. */ +function isSessionReferenceContext(event: SessionEvent): event is SessionEvent<'user/message'> { + if (event.type !== 'user/message') return false + const source = event.data.source as { readonly kind?: unknown; readonly form?: unknown } + return source.kind === 'session-reference' && source.form === 'recall' +} + +/** Replace all text blocks with one edited block while retaining non-text content in place. */ +function replaceMessageText(content: readonly ContentBlock[], text: string): ContentBlock[] { + const result: ContentBlock[] = [] + let foundText = false + for (const block of content) { + if (block.type !== 'text') { + result.push(block) + continue + } + if (foundText) continue + foundText = true + if (text !== '') result.push({ type: 'text', text }) + } + /* v8 ignore next -- resolveEditTarget requires a text block before calling this helper. */ + if (!foundText) throw new Error('editable message contains no text block') + if (result.length === 0) { + throw new RemoteError('gateway/bad-request', 'edited message cannot be empty', {}) + } + return result +} + +/** Build the stable rejection for a message that cannot be edited. */ +function editUnavailable(request: SessionEditRequest, reason: string): RemoteError<'session/edit-unavailable'> { + return new RemoteError( + 'session/edit-unavailable', + `session "${request.sessionId}" message ${String(request.messageSeq)} is not editable: ${reason}`, + { sessionId: request.sessionId, messageSeq: request.messageSeq }, + ) +} + +/** Wait until the exact edit message commits, or its owning turn closes without it. */ +function waitForEditedMessage( + ctx: Context, + agent: Agent, + messageId: MessageId, +): { readonly committed: Promise>; readonly dispose: () => void } { + let claimedTurn: number | undefined + const disposers: Array<() => void> = [] + const dispose = (): void => { + for (const disposeListener of disposers.splice(0)) disposeListener() + } + const promise = new Promise>((resolve, reject) => { + const finish = (outcome: { event: SessionEvent<'user/message'> } | { error: Error }): void => { + dispose() + if ('event' in outcome) resolve(outcome.event) + else reject(outcome.error) + } + disposers.push(ctx.on('agent/inbox/claimed', ({ agent: subject, message, turn }) => { + if (subject === agent && message.id === messageId) claimedTurn = turn + }, { global: true })) + disposers.push(ctx.on('agent/inbox/discarded', ({ agent: subject, message }) => { + if (subject !== agent || message.id !== messageId) return + finish({ error: new EditAdmissionStale('edit message was discarded before it committed') }) + }, { global: true })) + disposers.push(ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + if (event.type === 'user/message' && event.data.id === messageId) { + finish({ event }) + return + } + if (claimedTurn !== undefined + && event.type === 'turn/end' + && event.data.turn === claimedTurn) { + finish({ error: new EditAdmissionStale('edit turn ended before the replacement message committed') }) + } + }, { global: true })) + disposers.push(ctx.on('agent/disposed', ({ agent: subject }) => { + if (subject === agent) { + finish({ error: new EditAdmissionStale('edit agent was disposed before the replacement message committed') }) + } + }, { global: true })) + }) + return { committed: promise.finally(dispose), dispose } +} + function imageBlockIn( content: unknown, match: (ref: ImageAttachmentRef) => boolean, diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts index 9609da3fb5..fc0ad11d0d 100644 --- a/packages/api/session-controller/src/history.ts +++ b/packages/api/session-controller/src/history.ts @@ -4,9 +4,11 @@ import type { Context } from '@deepseek-ai/cordis' import { Deque } from '@deepseek-ai/dsh-deque' import { isAppendSurfaceEvent, + isConversationReplacementEvent, SessionLogOffset, SessionSeq, } from '@deepseek-ai/dsh-session' +import { foldConversation, isConversationSeqVisible } from '@deepseek-ai/dsh-session/conversation' import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' import type { SessionEvent, @@ -322,13 +324,18 @@ function paginate( throughSeq: SessionSeqCursor = events.at(-1)?.seq ?? -1, ): { readonly events: SessionEvent[]; readonly hasMore: boolean } { const end = SessionLogOffset(Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1)) + const conversation = foldConversation(events) let count = 0 let cut = SessionLogOffset(0) for (let index = end - 1; index >= 0; index--) { const event = events[index] as SessionEvent - if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue + if (!isConversationSeqVisible(event.seq, conversation.hiddenRanges)) continue + if (!MESSAGE_TYPES.has(event.type) + || (!isAppendSurfaceEvent(event) && !isConversationReplacementEvent(event))) continue count++ - const sources = event.sourceEventSeqs + const sources = isConversationReplacementEvent(event) + ? undefined + : event.sourceEventSeqs let groupStart = event.seq if (sources !== undefined) { for (const source of sources) { diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index e982bf6f72..9bc7f2b672 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -34,6 +34,8 @@ import type { SessionControlFrame, SessionCreateRequest, SessionCreateValue, + SessionEditRequest, + SessionEditValue, SessionFollowFrame, SessionFollowRequest, SessionForkRequest, @@ -337,6 +339,18 @@ export class SessionController extends TypertRemoteService { return this.commands.prompt(request) } + /** + * Replace the latest current turn-opening user message and rerun from that point. + * @param request - target message, optimistic revision, and replacement text. + * @param signal - caller cancellation before the replacement is admitted. + * @returns acknowledgement after the replacement message commits. + */ + @Remote('edit') + edit(request: SessionEditRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() + return this.commands.edit(request, signal) + } + /** * Read one image proven reachable from the addressed Session log. * @param request - Session and attachment identities used for authorization. diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 2e2a0d7c46..83d298fb39 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -196,6 +196,8 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'session/steer-unavailable': { readonly itemId: MessageId } 'session/title-invalid': { readonly sessionId: SessionId } 'session/fork-unavailable': { readonly sessionId: SessionId } + 'session/edit-unavailable': { readonly sessionId: SessionId; readonly messageSeq: number } + 'session/edit-stale': { readonly sessionId: SessionId; readonly messageSeq: number } 'subagent/not-found': { readonly parentSessionId: SessionId readonly childSessionId: SessionId @@ -313,6 +315,26 @@ export interface SessionPromptValue { readonly accepted: true } +/** Replace the latest current turn-opening user message and rerun from that point. */ +export interface SessionEditRequest { + /** Client-minted identity persisted on the replacement user message. */ + readonly requestId: SessionRequestId + readonly sessionId: SessionId + /** Exact current user-message event selected for editing. */ + readonly messageSeq: number + /** Latest human user-message seq observed when the editor opened. */ + readonly expectedLastUserSeq: number + /** Replacement text; non-text content is retained from the selected message. */ + readonly text: string + readonly clientTimeZone?: string +} + +/** Receipt after the replacement user message commits to the Session log. */ +export interface SessionEditValue { + readonly accepted: true + readonly messageSeq: number +} + /** Durable image read request. */ export interface SessionAttachmentRequest { readonly sessionId: SessionId @@ -403,6 +425,13 @@ export type SessionWireSurfaceOp = | 'append' | { readonly op: 'replace'; readonly start: number; readonly end: number } +/** Browser wire form of one user-facing conversation replacement. */ +export type SessionWireConversationOp = { + readonly op: 'replace' + readonly start: number + readonly end: number +} + /** Event-shaped wire representation of one packed chunk row. */ export type ChunkRowEvent = { [Kind in ChunkRow['type']]: { @@ -431,6 +460,7 @@ export interface SessionWireEvent { readonly ignorable?: true readonly sourceEventSeqs?: number[] readonly surfaceOp?: SessionWireSurfaceOp + readonly conversationOp?: SessionWireConversationOp } /** One message-aligned backwards-history request. */ diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index e3ea48f783..943136da04 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -11,6 +11,8 @@ import type { SessionAddress, SessionControlBaseline, SessionControlFrame, + SessionEditRequest, + SessionEditValue, SessionFollowFrame, SessionFollowRequest, SessionPage, @@ -141,6 +143,8 @@ export class FakeApiClient { () => Promise.resolve(ok({ records: [], hasMore: false })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onEdit: (payload: SessionEditRequest) => Promise> = () => + Promise.resolve(ok({ accepted: true as const, messageSeq: 0 })) onAttachment: (payload: unknown) => Promise> = () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) @@ -224,6 +228,7 @@ export class FakeApiClient { rename: payload => this.record('session.rename', payload, this.onRename(payload)), fork: payload => this.record('session.fork', payload, this.onFork(payload)), prompt: payload => this.record('session.prompt', payload, this.onPrompt(payload)), + edit: payload => this.record('session.edit', payload, this.onEdit(payload)), attachment: payload => this.record('session.attachment', payload, this.onAttachment(payload)), updateQueue: payload => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: payload => this.record('session.cancel', payload, this.onCancel(payload)), diff --git a/packages/api/session-controller/tests/session-edit.host.spec.ts b/packages/api/session-controller/tests/session-edit.host.spec.ts new file mode 100644 index 0000000000..b2c21cccc0 --- /dev/null +++ b/packages/api/session-controller/tests/session-edit.host.spec.ts @@ -0,0 +1,723 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import LlmRuntime, { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, SessionSeq } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SurfaceIntent } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRuntime from '@deepseek-ai/dsh-tools' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { describe, expect, it, vi } from 'vitest' +import { ApiSessionAgentController } from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' +import type { SessionEditRequest, SessionRequestId } from '../src/types.ts' +import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +function appendTurn(session: Session, turn: number, prompt: string, answer: string): SessionEvent<'user/message'> { + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: prompt }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('assistant/message', { + turn, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: answer }], + source: { provider: 'fixture', model: 'fixture-model' }, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [] }) + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + return user +} + +async function harness( + running = false, + inputModalities: readonly ('text' | 'image')[] | null = ['text', 'image'], + providers: readonly string[] = ['fixture'], +): Promise<{ + ctx: Context + controller: SessionCommandController + agent: Agent + session: Session + sent: Array<{ + message: Parameters[0] + followingMessages: readonly Parameters[0][] + intent: SurfaceIntent | undefined + position: string | undefined + }> + cancel: ReturnType +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + ctx.provide('llm', { + listProviders: () => providers.map(id => ({ id })), + resolveModelInfo: () => Promise.resolve(inputModalities === null ? {} : { inputModalities }), + } as never) + ctx.provide('agentDefaultModel', { + currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + saveSelection: () => Promise.resolve(), + } as never) + const session = ctx.sessions.create(SessionId('edit-session'), { meta: { cwd: '/workspace' } }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + let status: Agent['status'] = running ? 'running' : 'idle' + let nextTurn = 100 + const sent: Array<{ + message: Parameters[0] + followingMessages: readonly Parameters[0][] + intent: SurfaceIntent | undefined + position: string | undefined + }> = [] + const agent = { + id: session.id, + options: { provider: 'fixture', model: 'fixture-model' }, + session, + inbox, + get status() { return status }, + ctx, + send(message, _target, _wakeup, options) { + sent.push({ + message, + followingMessages: options?.followingMessages ?? [], + intent: options?.surfaceIntent, + position: options?.position, + }) + const turn = nextTurn + nextTurn += 1 + session.append('turn/start', { turn }) + session.append('step/start', { turn, step: 1 }) + session.append('user/message', message, options?.surfaceIntent ?? { surfaceOp: 'append' }) + for (const following of options?.followingMessages ?? []) { + session.append('user/message', following, { surfaceOp: 'append' }) + } + session.append('step/end', { turn, step: 1 }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + }, + followup: vi.fn(), + steer: vi.fn(), + inject: vi.fn(), + cancel: vi.fn(() => { + status = 'idle' + ctx.emit('agent/status', { agent, status: 'idle' }) + }), + runMaintenance: async operation => operation(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } satisfies Agent + const cancel = vi.mocked(agent.cancel) + ctx.agents.register(agent) + const selection: ModelSelectionRef = { + current: { provider: 'fixture', model: 'fixture-model' }, + assembled: undefined, + } + const agents = { + resolveAgent: () => Promise.resolve({ agent }), + selectionFor: () => selection, + serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), + } as unknown as ApiSessionAgentController + return { + ctx, + controller: new SessionCommandController(ctx, agents, '/workspace'), + agent, + session, + sent, + cancel, + } +} + +function request( + messageSeq: number, + expectedLastUserSeq: number, + text = 'edited', + clientTimeZone: string | undefined = 'UTC', +): SessionEditRequest { + return { + requestId: 'edit-request' as SessionRequestId, + sessionId: SessionId('edit-session'), + messageSeq, + expectedLastUserSeq, + text, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + } +} + +describe('session.edit', () => { + it.each([false, true])('replaces the latest turn and prioritizes the rerun (running=%s)', async (running) => { + const fixture = await harness(running) + appendTurn(fixture.session, 1, 'first', 'first answer') + const second = appendTurn(fixture.session, 2, 'second', 'second answer') + fixture.agent.inbox.append('next-turn', createUserMessage({ + content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' }, + })) + + const result = await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal) + + expect(result).toMatchObject({ accepted: true }) + expect(fixture.sent).toHaveLength(1) + expect(fixture.sent[0]).toMatchObject({ + message: { content: [{ type: 'text', text: 'edited' }] }, + position: 'front', + intent: { + surfaceOp: { op: 'replace', start: second.seq }, + conversationOp: { op: 'replace', start: second.seq - 2 }, + }, + }) + expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([ + [{ type: 'text', text: 'first' }], + [{ type: 'text', text: 'first answer' }], + [{ type: 'text', text: 'edited' }], + ]) + expect(fixture.cancel).toHaveBeenCalledTimes(running ? 1 : 0) + if (running) expect(fixture.cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + expect(fixture.agent.inbox.nextTurn.map(message => message.content)).toEqual([ + [{ type: 'text', text: 'queued' }], + ]) + await fixture.ctx.fiber.dispose() + }) + + it('preserves non-text blocks and permits unchanged text', async () => { + const fixture = await harness() + fixture.session.append('turn/start', { turn: 1 }) + fixture.session.append('step/start', { turn: 1, step: 1 }) + const prompt = fixture.session.append('user/message', createUserMessage({ + content: [ + { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'same' }, + { type: 'text', text: ' second block' }, + ], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'referenced context' }], + source: { + kind: 'session-reference', + form: 'recall', + version: 1, + references: [], + } as never, + }), { surfaceOp: 'append' }) + fixture.session.append('step/end', { turn: 1, step: 1 }) + fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + await fixture.controller.edit(request(prompt.seq, prompt.seq, 'same second block'), new AbortController().signal) + + expect(fixture.sent[0]?.message.content).toEqual([ + { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'same second block' }, + ]) + expect(fixture.sent[0]?.followingMessages).toEqual([ + expect.objectContaining({ content: [{ type: 'text', text: 'referenced context' }] }), + ]) + expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([ + [ + { type: 'image', attachment: { attachmentId: 'image', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'same second block' }, + ], + [{ type: 'text', text: 'referenced context' }], + ]) + await fixture.ctx.fiber.dispose() + }) + + it('rejects a preserved image before interrupting a text-only model', async () => { + const fixture = await harness(true, ['text']) + fixture.session.append('turn/start', { turn: 1 }) + fixture.session.append('step/start', { turn: 1, step: 1 }) + const prompt = fixture.session.append('user/message', createUserMessage({ + content: [ + { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'describe' }, + ], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + + await expect(fixture.controller.edit( + request(prompt.seq, prompt.seq, 'describe again'), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/attachment-invalid' }) + expect(fixture.cancel).not.toHaveBeenCalled() + expect(fixture.sent).toEqual([]) + await fixture.ctx.fiber.dispose() + }) + + it('accepts a preserved image when the current model declares no modality list', async () => { + const fixture = await harness(false, null) + fixture.session.append('turn/start', { turn: 1 }) + fixture.session.append('step/start', { turn: 1, step: 1 }) + const prompt = fixture.session.append('user/message', createUserMessage({ + content: [ + { type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + { type: 'text', text: 'describe' }, + ], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + + await expect(fixture.controller.edit( + request(prompt.seq, prompt.seq, 'describe again'), + new AbortController().signal, + )).resolves.toMatchObject({ accepted: true }) + await fixture.ctx.fiber.dispose() + }) + + it('rejects invalid timezone and unavailable-model requests before interruption', async () => { + const invalidZone = await harness(true) + const zoneTarget = appendTurn(invalidZone.session, 1, 'prompt', 'answer') + await expect(invalidZone.controller.edit({ + ...request(zoneTarget.seq, zoneTarget.seq), + clientTimeZone: 'not/a-zone', + }, new AbortController().signal)).rejects.toMatchObject({ code: 'session/invalid-time-zone' }) + expect(invalidZone.cancel).not.toHaveBeenCalled() + await invalidZone.ctx.fiber.dispose() + + const unavailable = await harness(true, ['text'], []) + const modelTarget = appendTurn(unavailable.session, 1, 'prompt', 'answer') + await expect(unavailable.controller.edit( + request(modelTarget.seq, modelTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/model-unavailable' }) + expect(unavailable.cancel).not.toHaveBeenCalled() + await unavailable.ctx.fiber.dispose() + }) + + it('honors cancellation before resolving the target Session', async () => { + const fixture = await harness() + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + const controller = new AbortController() + controller.abort(new Error('request stopped')) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + controller.signal, + )).rejects.toThrow('request stopped') + expect(fixture.sent).toEqual([]) + await fixture.ctx.fiber.dispose() + }) + + it('maps an admission race to stale and preserves an unexpected send failure as internal', async () => { + const raced = await harness() + const racedTarget = appendTurn(raced.session, 1, 'prompt', 'answer') + vi.spyOn(raced.agent, 'runMaintenance').mockImplementation(async (operation) => { + appendTurn(raced.session, 2, 'newer prompt', 'newer answer') + return operation(new AbortController().signal) + }) + await expect(raced.controller.edit( + request(racedTarget.seq, racedTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-stale' }) + await raced.ctx.fiber.dispose() + + const failedSend = await harness() + const sendTarget = appendTurn(failedSend.session, 1, 'prompt', 'answer') + const failure = new Error('send failed') + vi.spyOn(failedSend.agent, 'send').mockImplementation(() => { throw failure }) + await expect(failedSend.controller.edit( + request(sendTarget.seq, sendTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'gateway/internal', cause: failure }) + await failedSend.ctx.fiber.dispose() + }) + + it.each([new Error('cancel failed'), 'cancel failed'])('preserves a thrown cancellation (%s) as an internal failure', async (reason) => { + const fixture = await harness(true) + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => { throw reason }) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'gateway/internal' }) + await fixture.ctx.fiber.dispose() + }) + + it.each([false, true])('reports an occupied maintenance slot as busy (running=%s)', async (running) => { + const fixture = await harness(running) + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + vi.spyOn(fixture.agent, 'runMaintenance').mockImplementation(() => { throw new Error('maintenance busy') }) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + new AbortController().signal, + )).rejects.toMatchObject({ + code: 'session/agent-busy', + details: { reason: 'Error: maintenance busy' }, + }) + await fixture.ctx.fiber.dispose() + }) + + it.each([new Error('request stopped'), 'request stopped'])('honors caller cancellation while waiting for idle (%s)', async (reason) => { + const fixture = await harness(true) + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + const cancel = vi.spyOn(fixture.agent, 'cancel').mockImplementation(() => {}) + const controller = new AbortController() + const editing = fixture.controller.edit(request(target.seq, target.seq), controller.signal) + + await vi.waitFor(() => { expect(cancel).toHaveBeenCalledOnce() }) + fixture.ctx.emit('agent/status', { agent: {} as Agent, status: 'idle' }) + fixture.ctx.emit('agent/status', { agent: fixture.agent, status: 'running' }) + controller.abort(reason) + + await expect(editing).rejects.toThrow(reason instanceof Error ? reason.message : 'edit request aborted') + await fixture.ctx.fiber.dispose() + }) + + it('rejects when the edit message is discarded or its claimed turn closes before commit', async () => { + const discarded = await harness() + const discardedTarget = appendTurn(discarded.session, 1, 'prompt', 'answer') + vi.spyOn(discarded.agent, 'send').mockImplementation((message) => { + discarded.ctx.emit('agent/inbox/claimed', { agent: {} as Agent, message, turn: 8 }) + discarded.ctx.emit('agent/inbox/claimed', { + agent: discarded.agent, + message: createUserMessage({ content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }), + turn: 8, + }) + discarded.ctx.emit('agent/inbox/discarded', { agent: {} as Agent, message }) + discarded.ctx.emit('agent/inbox/discarded', { agent: discarded.agent, message }) + }) + await expect(discarded.controller.edit( + request(discardedTarget.seq, discardedTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-stale' }) + await discarded.ctx.fiber.dispose() + + const closed = await harness() + const closedTarget = appendTurn(closed.session, 1, 'prompt', 'answer') + const unrelatedSession = closed.ctx.sessions.create(SessionId('unrelated-edit-session')) + vi.spyOn(closed.agent, 'send').mockImplementation((message) => { + closed.ctx.emit('agent/inbox/claimed', { agent: closed.agent, message, turn: 9 }) + closed.ctx.emit('session/event', unrelatedSession, { + type: 'turn/end', seq: SessionSeq(0), time: 0, data: { turn: 9, reason: { kind: 'blocked' } }, + }) + closed.session.append('turn/end', { turn: 9, reason: { kind: 'blocked' } }) + }) + await expect(closed.controller.edit( + request(closedTarget.seq, closedTarget.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-stale' }) + await closed.ctx.fiber.dispose() + }) + + it('rejects when the Agent is disposed before the replacement commits', async () => { + const fixture = await harness() + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + vi.spyOn(fixture.agent, 'send').mockImplementation(() => { + fixture.ctx.emit('agent/disposed', { agent: {} as Agent }) + fixture.ctx.emit('agent/disposed', { agent: fixture.agent }) + }) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-stale' }) + await fixture.ctx.fiber.dispose() + }) + + it('allows the current replacement message to be edited again', async () => { + const fixture = await harness() + const original = appendTurn(fixture.session, 1, 'original', 'answer') + const first = await fixture.controller.edit( + request(original.seq, original.seq, 'first edit'), + new AbortController().signal, + ) + + const second = await fixture.controller.edit( + request(first.messageSeq, first.messageSeq, 'second edit'), + new AbortController().signal, + ) + + expect(second.messageSeq).toBeGreaterThan(first.messageSeq) + expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([ + [{ type: 'text', text: 'second edit' }], + ]) + await fixture.ctx.fiber.dispose() + }) + + it('retains a later compaction checkpoint positioned before the edited turn', async () => { + const fixture = await harness() + const first = appendTurn(fixture.session, 1, 'first', 'first answer') + const second = appendTurn(fixture.session, 2, 'second', 'second answer') + const firstAnswer = fixture.session.snapshotEvents().find(event => + event.type === 'assistant/message' && event.data.turn === 1) + if (firstAnswer?.type !== 'assistant/message') throw new Error('missing first answer') + const checkpoint = fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: first.seq, end: firstAnswer.seq }, + sourceEventSeqs: [first.seq, firstAnswer.seq], + }) + + await fixture.controller.edit(request(second.seq, second.seq), new AbortController().signal) + + expect(fixture.sent[0]?.intent?.surfaceOp).toMatchObject({ + op: 'replace', + start: second.seq, + }) + expect(fixture.session.surface.nodes[0]).toBe(checkpoint.seq) + expect(fixture.session.deriveMessages().map(message => message.content)).toEqual([ + [{ type: 'text', text: 'summary' }], + [{ type: 'text', text: 'edited' }], + ]) + await fixture.ctx.fiber.dispose() + }) + + it('accepts legacy turns whose opening user message precedes step/start', async () => { + const fixture = await harness() + fixture.session.append('turn/start', { turn: 1 }) + const prompt = fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'legacy prompt' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + fixture.session.append('step/start', { turn: 1, step: 1 }) + fixture.session.append('step/end', { turn: 1, step: 1 }) + fixture.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + await expect(fixture.controller.edit( + request(prompt.seq, prompt.seq, 'edited legacy prompt', undefined), + new AbortController().signal, + )).resolves.toMatchObject({ accepted: true }) + expect(fixture.sent[0]?.message.content).toEqual([{ type: 'text', text: 'edited legacy prompt' }]) + await fixture.ctx.fiber.dispose() + }) + + it('rejects stale, shadowed, steering, empty, and malformed requests before admission', async () => { + const fixture = await harness() + const first = appendTurn(fixture.session, 1, 'first', 'first answer') + const second = appendTurn(fixture.session, 2, 'second', 'second answer') + const expectCode = async (value: Promise, code: string): Promise => { + await expect(value).rejects.toMatchObject({ code }) + } + + await expectCode( + fixture.controller.edit(request(first.seq, first.seq), new AbortController().signal), + 'session/edit-stale', + ) + await expectCode( + fixture.controller.edit(request(first.seq, second.seq), new AbortController().signal), + 'session/edit-unavailable', + ) + await expectCode( + fixture.controller.edit(request(-1, second.seq), new AbortController().signal), + 'gateway/bad-request', + ) + await expectCode( + fixture.controller.edit(request(first.seq, -1), new AbortController().signal), + 'gateway/bad-request', + ) + await expectCode( + fixture.controller.edit(request(second.seq, second.seq, ''), new AbortController().signal), + 'gateway/bad-request', + ) + + const rewritten = fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' }, + }), { + surfaceOp: { op: 'replace', start: first.seq, end: SessionSeq(second.seq + 1) }, + sourceEventSeqs: [ + first.seq, + SessionSeq(first.seq + 1), + second.seq, + SessionSeq(second.seq + 1), + ], + conversationOp: { + op: 'replace', + start: SessionSeq(first.seq - 2), + end: SessionSeq(fixture.session.seq - 1), + }, + }) + await expectCode( + fixture.controller.edit(request(first.seq, rewritten.seq), new AbortController().signal), + 'session/edit-unavailable', + ) + await fixture.ctx.fiber.dispose() + }) + + it('rejects the latest human message after compaction removes it from the current surface', async () => { + const fixture = await harness() + const target = appendTurn(fixture.session, 1, 'prompt', 'answer') + const answer = fixture.session.snapshotEvents().find(event => + event.type === 'assistant/message' && event.data.turn === 1) + if (answer?.type !== 'assistant/message') throw new Error('missing answer') + fixture.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: target.seq, end: answer.seq }, + sourceEventSeqs: [target.seq, answer.seq], + }) + + await expect(fixture.controller.edit( + request(target.seq, target.seq), + new AbortController().signal, + )).rejects.toMatchObject({ code: 'session/edit-unavailable' }) + await fixture.ctx.fiber.dispose() + }) + + it('rejects non-human, textless, turnless, steered, and step-less targets', async () => { + const expectCode = async (value: Promise): Promise => { + await expect(value).rejects.toMatchObject({ code: 'session/edit-unavailable' }) + } + + const nonHuman = await harness() + nonHuman.session.append('turn/start', { turn: 1 }) + nonHuman.session.append('step/start', { turn: 1, step: 1 }) + const context = nonHuman.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' }, + }), { surfaceOp: 'append' }) + const human = nonHuman.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'human' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(nonHuman.controller.edit(request(context.seq, human.seq), new AbortController().signal)) + await nonHuman.ctx.fiber.dispose() + + const textless = await harness() + textless.session.append('turn/start', { turn: 1 }) + textless.session.append('step/start', { turn: 1, step: 1 }) + const image = textless.session.append('user/message', createUserMessage({ + content: [{ type: 'image', attachment: { attachmentId: 'image' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(textless.controller.edit(request(image.seq, image.seq), new AbortController().signal)) + await textless.ctx.fiber.dispose() + + const turnless = await harness() + const lone = turnless.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'lone' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(turnless.controller.edit(request(lone.seq, lone.seq), new AbortController().signal)) + await turnless.ctx.fiber.dispose() + + const stepLess = await harness() + stepLess.session.append('turn/start', { turn: 1 }) + const noStep = stepLess.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'no step' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(stepLess.controller.edit(request(noStep.seq, noStep.seq), new AbortController().signal)) + await stepLess.ctx.fiber.dispose() + + const steered = await harness() + const opening = appendTurn(steered.session, 1, 'opening', 'answer') + steered.session.append('turn/start', { turn: 2 }) + steered.session.append('step/start', { turn: 2, step: 1 }) + const current = steered.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'current' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + steered.session.append('step/end', { turn: 2, step: 1 }) + steered.session.append('step/start', { turn: 2, step: 2 }) + const steering = steered.session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'steering' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + await expectCode(steered.controller.edit(request(steering.seq, steering.seq), new AbortController().signal)) + expect(opening.seq).toBeLessThan(current.seq) + await steered.ctx.fiber.dispose() + }) +}) + +describe('session.edit with AgentLoop', () => { + it('runs the edited turn before preserved Queue work through the real loop', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const adapter = new MockAdapter([ + textResponse('old answer'), + textResponse('edited answer'), + textResponse('queued answer'), + ]) + ctx.llm.registerAdapter(['fixture'], adapter) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + cwd: '/workspace', + }) + const agent = await ctx.agentLoop.create(SessionId('real-edit'), { + provider: 'fixture', model: 'fixture-model', + }) + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, + })) + await agent.whenIdle() + const target = agent.session.snapshotEvents().find(event => event.type === 'user/message') + if (target?.type !== 'user/message') throw new Error('missing original prompt') + agent.inbox.append('next-turn', createUserMessage({ + content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' }, + })) + + const result = await remote.edit({ + requestId: 'real-edit-request' as SessionRequestId, + sessionId: agent.id, + messageSeq: target.seq, + expectedLastUserSeq: target.seq, + text: 'edited', + clientTimeZone: 'UTC', + }) + expect(result).toMatchObject({ ok: true, value: { accepted: true } }) + await agent.whenIdle() + + const requests = adapter.requests.map(request => JSON.stringify(request.messages)) + expect(requests).toHaveLength(3) + expect(requests[1]).toContain('edited') + expect(requests[1]).not.toContain('original') + expect(requests[1]).not.toContain('queued') + expect(requests[2]).toContain('queued') + expect(agent.session.deriveMessages().map(message => message.content)).toEqual([ + [{ type: 'text', text: 'edited' }], + [{ type: 'text', text: 'edited answer' }], + [{ type: 'text', text: 'queued' }], + [{ type: 'text', text: 'queued answer' }], + ]) + await ctx.fiber.dispose() + }) + + it('interrupts a running turn before admitting the edited rerun', async () => { + const ctx = new Context() + await ctx.plugin(LlmRuntime) + await ctx.plugin(SessionStore) + await ctx.plugin(SessionProjectionRegistry) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(ToolRuntime) + await ctx.plugin(AgentRegistry) + await ctx.plugin(AgentLoop, { agents: [] }) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const adapter = new MockAdapter(['hang', textResponse('edited answer')]) + ctx.llm.registerAdapter(['fixture'], adapter) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + cwd: '/workspace', + }) + const agent = await ctx.agentLoop.create(SessionId('running-edit'), { + provider: 'fixture', model: 'fixture-model', + }) + agent.followup(createUserMessage({ + content: [{ type: 'text', text: 'original' }], source: { kind: 'user' }, + })) + await vi.waitFor(() => { + expect(adapter.requests).toHaveLength(1) + }) + const target = agent.session.snapshotEvents().find(event => event.type === 'user/message') + if (target?.type !== 'user/message') throw new Error('missing running prompt') + + const result = await remote.edit({ + requestId: 'running-edit-request' as SessionRequestId, + sessionId: agent.id, + messageSeq: target.seq, + expectedLastUserSeq: target.seq, + text: 'edited while running', + }) + expect(result).toMatchObject({ ok: true }) + await agent.whenIdle() + + expect(agent.session.snapshotEvents().filter(event => event.type === 'turn/end').map(event => + event.type === 'turn/end' ? event.data.reason.kind : '')).toEqual(['aborted', 'completed']) + expect(JSON.stringify(adapter.requests[1]?.messages)).toContain('edited while running') + expect(JSON.stringify(adapter.requests[1]?.messages)).not.toContain('original') + await ctx.fiber.dispose() + }) +}) diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts index 4dcd66d8da..caeba44028 100644 --- a/packages/api/session-controller/tests/session-history-journal.host.spec.ts +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -254,6 +254,51 @@ describe('Session history raw journal', () => { expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index)) }) + it('counts an edit replacement as the current message without pulling its hidden tail into the page', async () => { + const { ctx } = await harness() + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const firstStart = session.append('turn/start', { turn: 1 }) + const first = appendUserText(session, 'first prompt') + appendAssistantText(session, 'first reply', 1) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2 }) + const second = appendUserText(session, 'second prompt') + const secondAnswer = appendAssistantText(session, 'second reply', 2) + const secondEnd = session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + const shadowed = session.surface.nodes.slice(session.surface.nodes.indexOf(first.seq)) + session.append('turn/start', { turn: 3 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'edited prompt' }], source: { kind: 'user' }, + }), { + surfaceOp: { op: 'replace', start: first.seq, end: secondAnswer.seq }, + sourceEventSeqs: [...shadowed], + conversationOp: { op: 'replace', start: firstStart.seq, end: secondEnd.seq }, + }) + const editedAnswer = appendAssistantText(session, 'edited reply', 3) + + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + maxMessages: 2, + }) + if (!response.ok) throw new Error('unreachable') + const messages = pageEvents(response.value) + .filter(event => event.type === 'user/message' || event.type === 'assistant/message') + expect(messages.map(event => event.seq)).toEqual([editedAnswer.seq - 1, editedAnswer.seq]) + expect(messages.some(event => event.seq === second.seq)).toBe(false) + expect(response.value.hasMore).toBe(true) + + const expanded = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + maxMessages: 3, + }) + if (!expanded.ok) throw new Error('unreachable') + expect(pageEvents(expanded.value).some(event => event.seq === second.seq)).toBe(true) + expect(expanded.value.hasMore).toBe(false) + }) + it('paginates a message with many provenance sources without variadic argument expansion', async () => { const { ctx } = await harness() const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) diff --git a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts index c0bad5dabd..134c72e1bb 100644 --- a/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts +++ b/packages/api/session-controller/tests/session-pending-submissions.client.spec.ts @@ -4,11 +4,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import { SessionSeq, type SessionEvent, type SessionId } from '@deepseek-ai/dsh-session/types' -import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' +import { RemoteError, type RemoteResult } from '@deepseek-ai/dsh-typert-protocol' import { Session } from '../src/client/sessions/session.ts' import type { PendingSubmissionRetirement } from '../src/client/contract/session.ts' import type { SessionQueuedItem, SessionRequestId } from '../src/types.ts' -import { FakeApiClient, err, fakeRemote, ok } from './fake-api.client.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' import { historyValue } from './event-script.client.ts' const SID = 'fk-s1' as SessionId @@ -254,6 +254,63 @@ describe('observed retirement', () => { }) }) +describe('pending edit', () => { + it('publishes optimistic state immediately and retires it after the durable replacement', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(ok(historyValue([]))) + await session.open() + const response = deferred>() + api.onEdit = () => response.promise + + const editing = session.edit(10, 12, 'edited') + const pending = session.getSnapshot().pendingEdit + expect(pending).toMatchObject({ targetSeq: 10, expectedLastUserSeq: 12, text: 'edited' }) + const request = api.callsOf('session.edit')[0] as { requestId: SessionRequestId } + await api.pushFollow(SID, { type: 'event', event: promptEvent(SessionSeq(0), request.requestId) as never }) + await settleFrames() + expect(session.getSnapshot().pendingEdit).toBeNull() + + response.resolve(ok({ accepted: true, messageSeq: 0 })) + await expect(editing).resolves.toEqual(ok({ accepted: true, messageSeq: 0 })) + }) + + it('restores the durable view and exposes promptError when admission fails', async () => { + const { api, session } = makeSession() + api.onEdit = () => Promise.resolve(err(new RemoteError( + 'session/edit-stale', + 'stale', + { sessionId: SID, messageSeq: 10 }, + ))) + + const result = await session.edit(10, 12, 'edited') + + expect(result.ok).toBe(false) + expect(session.getSnapshot().pendingEdit).toBeNull() + expect(session.getSnapshot().promptError).toMatchObject({ op: 'edit' }) + }) + + it('normalizes a thrown Remote failure and rethrows an unexpected Client fault', async () => { + const remoteFailure = new RemoteError('gateway/internal', 'connection failed', {}) + const failed = makeSession() + failed.api.onEdit = () => Promise.reject(remoteFailure) + + await expect(failed.session.edit(10, 12, 'edited')).resolves.toEqual({ + ok: false, + error: remoteFailure, + }) + expect(failed.session.getSnapshot()).toMatchObject({ + pendingEdit: null, + promptError: { op: 'edit', error: remoteFailure }, + }) + + const faulted = makeSession() + const fault = new Error('client assembly failed') + faulted.api.onEdit = () => Promise.reject(fault) + await expect(faulted.session.edit(10, 12, 'edited')).rejects.toBe(fault) + expect(faulted.session.getSnapshot()).toMatchObject({ pendingEdit: null, promptError: null }) + }) +}) + describe('disposal', () => { it('retires unsettled echoes as failed and preserves an already-observed settlement', async () => { const { api, session } = makeSession() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 1b5859b509..1a85f0c1e0 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -34,6 +34,8 @@ import type { SessionControlFrame, SessionCreateRequest, SessionCreateValue, + SessionEditRequest, + SessionEditValue, SessionForkRequest, SessionForkValue, SessionFollowFrame, @@ -67,6 +69,7 @@ export interface TestSessionRemote { rename(request: SessionRenameRequest): Promise> fork(request: SessionForkRequest): Promise> prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise> + edit(request: SessionEditRequest, signal?: AbortSignal): Promise> attachment(request: SessionAttachmentRequest): Promise> updateQueue(request: SessionUpdateQueueRequest): Promise> cancel(request: SessionCancelRequest): Promise> @@ -316,6 +319,10 @@ export function createSessionTestRemote( () => direct.prompt(request, signal), signal, ), + edit: (request, signal = new AbortController().signal) => remoteResult( + () => direct.edit(request, signal), + signal, + ), attachment: request => remoteResult(() => direct.attachment(request)), updateQueue: request => remoteResult(() => direct.updateQueue(request)), cancel: request => remoteResult(() => direct.cancel(request)), diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 8183f95981..df1d4efad1 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/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-chat/README.md -README.md: 79214bf2feb8384cecbb152aa31af162dc6dfdde -README.zh.md: 674959e1974c74abba75f179eff50fe8488458fc +README.md: 00943283c1f0d52b684c67240bb8f7b8d861f091 +README.zh.md: 542563eb63b6fe2dd7a945268a7d834ee50652fe diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 79214bf2fe..00943283c1 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,11 +8,12 @@ English | [中文](README.zh.md) ## Summary -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the destination selected when submission begins: transcript echoes render at the flow tail, steering echoes carry the pending marker, and queued echoes stay out of Chat. Each echo disappears in the same render that exposes its durable user/steering node or Queue occurrence. `SessionSnapshot.pendingEdit` similarly replaces the selected suffix until the durable edited generation is renderable. ## Table of Contents - [System prompt row](#system-prompt-row) +- [Message editing](#message-editing) - [Turn token usage](#turn-token-usage) - [Turn Process Folding](#turn-process-folding) - [Scroll ownership](#scroll-ownership) @@ -29,6 +30,15 @@ Chat shows a collapsed `System prompt` row for each non-empty initial or resumed ----- + +## Message editing + +Only the latest human message exposes Edit, and only when it is a current-surface, turn-opening prompt in an ordinary Session. Earlier messages, compacted messages, steering, injected context, and direct subagent conversations do not. Edit replaces the bubble with a composer-style input card that fills the Chat content width: input-surface fill, border and elevation, plus an internal cancel/save action row. Its textarea starts at 80px, grows with its content up to 240px, and then scrolls internally. Enter submits, Shift+Enter inserts a newline, and Escape cancels. The normal composer remains enabled; starting another submission closes the inline editor. + +Submission moves the edited message to the transcript tail immediately and hides every later row while the Host validates and commits the replacement. A failure restores the durable conversation and appears through the normal prompt-error channel. A successful replacement has a new timestamp and no edited badge or Undo action; its retained attachments and session-reference labels remain on the replacement. The existing Session title is unchanged. + +----- + ## Turn token usage diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 674959e197..542563eb63 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,11 +8,12 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的目标:transcript 回显位于消息流末尾,steering 回显带 pending 标记,queued 回显不进入 Chat。每条回显都在同一次渲染中由其持久 user/steering 节点或 Queue occurrence 接替。`SessionSnapshot.pendingEdit` 同样会在持久编辑代次可渲染前替换选中的后缀。 ## 目录 - [系统提示词行](#system-prompt-row) +- [消息编辑](#message-editing) - [轮次 token 用量](#turn-token-usage) - [轮次过程折叠](#turn-process-folding) - [滚动归属](#scroll-ownership) @@ -29,6 +30,15 @@ Chat 会为每个非空的初始或恢复请求、显式消息序列起点或真 ----- + +## 消息编辑 + +只有最新的人工消息会显示「编辑消息」,且它必须是普通 Session 中仍位于当前 surface 的轮次开场提示词。更早的消息、被压缩的消息、steering、注入上下文和 direct subagent 对话都不显示该动作。编辑会把气泡替换为铺满 Chat 内容列的 composer 风格输入卡片:使用输入表面背景、边框和阴影,并在卡片内部放置取消/保存操作行。文本框初始高度为 80px,随内容增长至 240px 后改为内部滚动。Enter 提交,Shift+Enter 插入换行,Escape 取消。普通 composer 保持可用;开始另一条提交会关闭行内编辑器。 + +提交后,编辑消息会立即移到 transcript 末尾,并在 Host 校验和提交替换期间隐藏其后的所有行。失败会恢复持久对话,并通过常规 prompt 错误通道展示。成功的替换使用新时间戳,不显示已编辑标记,也不提供 Undo;保留的附件和会话引用标签继续显示在替换消息上。已有 Session 标题保持不变。 + +----- + ## 轮次 token 用量 diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 0c7d87f344..108cb74818 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -146,6 +146,10 @@ export function apply(ctx: Context): void { // Fork or child-title failure leaves the source view unchanged. }) }, + editMessage: async (seq, expectedLastUserSeq, text) => { + const result = await session.edit(seq, expectedLastUserSeq, text) + if (!result.ok) throw new Error(`message edit failed: ${result.error.code}: ${result.error.message}`) + }, } }, }, ChatView) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index e342836fe5..bd14b7fd21 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -37,7 +37,7 @@ function turnOf(node: ChatNode | undefined): number | undefined { /** Subscribe, apply Turn-process visibility, and dispatch one stable Context key. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, useChatNode, useChatNodeProcess, historyIncomplete, compactTranscript, - selectedCallId, cwd, openFile, inspectCall, forkAt, + selectedCallId, cwd, openFile, inspectCall, forkAt, messageEdit, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { const node = useChatNode(nodeKey) @@ -109,12 +109,13 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ inspectCall, forkAt, loadImage, + messageEdit, renderMessageImages, fileMentions, turnProcess, }, [ node, selectedCallId, cwd, openFile, inspectCall, forkAt, - loadImage, renderMessageImages, fileMentions, turnProcess, + loadImage, messageEdit, renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null const turnData = turnDataOf(routedNode) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index a8dc57d321..42585cd195 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -7,9 +7,10 @@ import type { } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSeq } from '@deepseek-ai/dsh-session/types' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, MessageEditController } from '../contract/slots.ts' +import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' -import { PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' +import { PendingEditBubble, PendingSteeringBubble, PendingSubmissionBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { TurnNavigator } from './TurnNavigator.tsx' import { mergeTurnRailItems, type TurnRailItem } from './turn-rail-items.ts' @@ -217,10 +218,17 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod export function ChatView({ useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot, sessionId, openFile, loadOlder, loadThrough, loadImage, openView, chatScroll, forkAt, fileMentions, - useTranscriptView, useProjection, t, + editMessage, useTranscriptView, useProjection, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) const nodeStore = useChat(s => s.nodes) + const latestUserSeq = useChat((snapshot) => { + for (let index = snapshot.order.length - 1; index >= 0; index -= 1) { + const node = snapshot.nodes.get(snapshot.order[index] ?? '') as ChatNode | undefined + if (node?.kind === 'user' || node?.kind === 'steering') return node.data.seq + } + return -1 + }) // The rail's items are accumulated in the Chat snapshot, so this selector is // both the data and its change signal: the array identity moves only when a // Turn enters, leaves, or changes its preview. @@ -234,6 +242,9 @@ export function ChatView({ ) const timeline = useChat(s => s.timeline) const inbox = useSession(s => s.queue) + const pendingSubmissions = useSession(s => s.pendingSubmissions) + const pendingEdit = useSession(s => s.pendingEdit) + const ordinarySession = useSession(s => s.subagent === null) // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const running = useSession(s => s.running) @@ -248,6 +259,53 @@ export function ChatView({ }, [openView]) const [fileOpenError, setFileOpenError] = useState<{ path: string; message: string } | null>(null) const [fileOpenBusy, setFileOpenBusy] = useState(false) + const [editingMessage, setEditingMessage] = useState<{ + seq: number + text: string + expectedLastUserSeq: number + } | null>(null) + const previousSubmissionIds = useRef( + new Set(pendingSubmissions.map(submission => submission.requestId)), + ) + useEffect(() => { + const previous = previousSubmissionIds.current + const next = new Set(pendingSubmissions.map(submission => submission.requestId)) + previousSubmissionIds.current = next + if (pendingSubmissions.some(submission => !previous.has(submission.requestId))) { + setEditingMessage(null) + } + }, [pendingSubmissions]) + const messageEdit = useMemo(() => ({ + latestUserSeq, + current: editingMessage, + begin: (seq, text, expectedLastUserSeq) => { + setEditingMessage({ seq, text, expectedLastUserSeq }) + }, + change: (text) => { setEditingMessage(current => current === null ? null : { ...current, text }) }, + submit: () => { + const current = editingMessage + if (current === null || editMessage === undefined) return + setEditingMessage(null) + void editMessage(current.seq, current.expectedLastUserSeq, current.text).catch((error: unknown) => { + // Session business and carrier failures render through promptError; only an unexpected Client fault reaches here. + console.error('[ui-chat] message edit failed:', error) + }) + }, + cancel: () => { setEditingMessage(null) }, + }), [editMessage, editingMessage, latestUserSeq]) + const messageEditController = editMessage === undefined || !ordinarySession || pendingEdit !== null + ? undefined + : messageEdit + const pendingEditTarget = pendingEdit === null + ? undefined + : nodeStore.values().find((node): node is ChatNode<'user'> => { + const candidate = node as ChatNode + return candidate.kind === 'user' && candidate.data.seq === pendingEdit.targetSeq + }) + const pendingEditIndex = pendingEditTarget === undefined + ? -1 + : order.indexOf(pendingEditTarget.key) + const displayedOrder = pendingEditIndex < 0 ? order : order.slice(0, pendingEditIndex) // Close/retry must ignore a settlement that started before the latest // gesture; otherwise a cancelled in-flight refusal reopens the dialog. const fileOpenRequest = useRef(0) @@ -285,7 +343,6 @@ export function ChatView({ () => inbox.filter(item => item.placement === 'steering'), [inbox], ) - const pendingSubmissions = useSession(s => s.pendingSubmissions) // Submission echoes still awaiting their durable counterpart. `order` is the // recompute trigger: durable user material always arrives as an append, and // every append replaces the order array. @@ -775,7 +832,7 @@ export function ChatView({ )} + {pendingEdit !== null && pendingEditTarget !== undefined && ( + + )} {/* No pending placeholders: questions (ui-user-questions) and approvals (ApprovalPanel) both take over the composer, so a flow card would double-render the same wait. */} diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx index 5476f1a5c0..10e48b6963 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx @@ -3,7 +3,7 @@ import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react' import { - IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard, + IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, IconEditOutline16, Tooltip, writeClipboard, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' import { formatMessageClock } from './message-chrome.ts' @@ -19,6 +19,8 @@ export interface MessageIconActionsProps { clock: 'start' | 'end' /** Fork the session at this message; omission hides the branch action. */ onBranch?: (() => void) | undefined + /** Edit the latest eligible human message in the current Session. */ + onEdit?: (() => void) | undefined /** The message is not a completed transcript tail, so branch stays visible but unavailable. */ branchUnavailable?: boolean | undefined /** Parent layout class composed onto the actions row. */ @@ -38,12 +40,12 @@ export interface MessageIconActionsProps { } /** - * Copy / branch (/ clock) IconActions row shared by user and assistant chrome. - * @param props - Copy text, event time, clock side, branch callback, className. + * Copy, optional edit/branch, and clock actions shared by message chrome. + * @param props - Copy text, event time, clock side, optional actions, and styling. * @returns The actions row element. */ export function MessageIconActions({ - text, time, clock, onBranch, branchUnavailable = false, className, + text, time, clock, onBranch, onEdit, branchUnavailable = false, className, extraActions, usageAction, t, }: MessageIconActionsProps) { const day = useCalendarDay() @@ -87,6 +89,13 @@ export function MessageIconActions({ {copied ? : } + {onEdit !== undefined && ( + + + + )} {extraActions} {onBranch !== undefined && ( diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.module.css b/packages/client/ui-chat/src/client/chat/MessageItem.module.css index 7c616f4a0e..6ee7836750 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageItem.module.css @@ -40,6 +40,94 @@ word-break: break-word; } +.editBubble { + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; + padding: 10px 10px 10px 16px; + border: 0; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l2); + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-elevation-soft); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.editStack { + width: 100%; + max-width: 100%; +} + +.editInput { + box-sizing: border-box; + display: block; + width: 100%; + min-height: 80px; + max-height: 240px; + padding: 0 6px 0 0; + resize: none; + border: none; + outline: none; + background: transparent; + color: inherit; + caret-color: var(--dsw-alias-state-business-primary); + font: inherit; + line-height: inherit; + white-space: pre-wrap; +} + +.editToolbar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.actions { + display: flex; + align-items: center; + gap: 6px; +} + +.editAction { + display: grid; + place-items: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 999px; + corner-shape: round; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.editAction:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.editAction:focus-visible { + outline: 2px solid var(--dsw-alias-label-tertiary); + outline-offset: -2px; +} + +.editSaveAction { + background: var(--dsw-alias-button-info-fill); + color: #fff; +} + +.editSaveAction:hover { + background: var(--dsw-alias-button-info-hover); +} + +.editSaveAction:focus-visible { + outline-color: var(--dsw-alias-state-business-primary); + outline-offset: 2px; +} + .referenceSummary { color: var(--dsw-alias-label-tertiary); font-size: var(--dsh-content-font-size-secondary, 13px); diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx index 07e9fb32a0..5dcfea124c 100644 --- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx @@ -1,8 +1,10 @@ -import { memo, useEffect, useMemo, useState } from 'react' +import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { ReactNode } from 'react' import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client' import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' +import { + IconCheckOutline16, IconCloseOutline16, JsonBlock, projectUserText, StateDot, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts' import { CompactionItem } from './CompactionItem.tsx' @@ -12,6 +14,18 @@ import css from './MessageItem.module.css' type UserImage = Extract +const MESSAGE_EDIT_MAX_HEIGHT = 240 +const MESSAGE_EDIT_INITIAL_HEIGHT = 80 + +const NO_MESSAGE_EDIT: NonNullable = { + latestUserSeq: -1, + current: null, + begin: () => {}, + change: () => {}, + submit: () => {}, + cancel: () => {}, +} + function contentParts(content: readonly unknown[]): { text: string images: { attachment: UserImage['attachment'] }[] @@ -31,6 +45,22 @@ function contentParts(content: readonly unknown[]): { return { text: texts.join(''), images, rest } } +function replaceTextContent(content: readonly unknown[], text: string): unknown[] { + let replaced = false + const result: unknown[] = [] + for (const block of content) { + const candidate = block as { type?: unknown } + if (candidate.type !== 'text') { + result.push(block) + continue + } + if (replaced) continue + replaced = true + if (text !== '') result.push({ type: 'text', text }) + } + return result +} + function retrySeconds(milliseconds: number): number { return Math.max(1, Math.ceil(milliseconds / 1_000)) } @@ -148,7 +178,7 @@ function TurnMaxTokensItem({ t }: { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t, + content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = 'always', t, }: { content: readonly unknown[] renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] @@ -162,6 +192,8 @@ function UserStyleBubble({ referenceLabels?: readonly string[] /** Local submission-echo previews replacing the content-derived image group. */ previewImages?: readonly MessageImageSource[] + /** Whole actions-row visibility: earlier rows reveal on hover, the latest stays shown. */ + reveal?: 'always' | 'hover' t: ChatViewSlotProps['t'] }): ReactNode { const { text, images: contentImages, rest } = contentParts(content) @@ -173,6 +205,7 @@ function UserStyleBubble({ className={css.userRow} data-pending-steering={pending || undefined} data-submission-echo={echo || undefined} + data-actions-reveal={reveal} >
{renderMessageImages({ images, align: 'end' })} @@ -269,16 +302,149 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }: ) } +/** Optimistic replacement shown from Edit submit until its durable message lands. */ +export function PendingEditBubble({ content, text, time, referenceLabels = [], renderMessageImages, t }: { + content: readonly unknown[] + text: string + time: number + referenceLabels?: readonly string[] + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + t: ChatViewSlotProps['t'] +}): ReactNode { + const replacement = useMemo(() => replaceTextContent(content, text), [content, text]) + return ( + ( + + )} + /> + ) +} + +function EditingUserBubble({ content, text, referenceLabels = [], renderMessageImages, change, submit, cancel, t }: { + content: readonly unknown[] + text: string + referenceLabels?: readonly string[] + renderMessageImages: ChatNodeOwnerProps['renderMessageImages'] + change: (text: string) => void + submit: () => void + cancel: () => void + t: ChatViewSlotProps['t'] +}): ReactNode { + const { images, rest } = contentParts(content) + const inputRef = useRef(null) + useLayoutEffect(() => { + const input = inputRef.current + if (input === null) return + input.style.height = '0px' + const height = Math.min(MESSAGE_EDIT_MAX_HEIGHT, Math.max(input.scrollHeight, MESSAGE_EDIT_INITIAL_HEIGHT)) + input.style.height = `${String(height)}px` + input.style.overflowY = input.scrollHeight > MESSAGE_EDIT_MAX_HEIGHT ? 'auto' : 'hidden' + }, [text]) + return ( +
+
+ {renderMessageImages({ images, align: 'end' })} +
+