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/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 4ee681f5f8..45af0af811 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 9c7f48607c15841515c1aa26e5569799ec6ef847 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 2e12a0495e231dd4bbc7d54e3a49c4bf04be6296 +2026-07-10-single-file-executable-sdk-runtime-distribution.md: ad0d06a0ba260ee2420a5896bdb35d0de9c9d5e7 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 7bac8a35ed9023358409e1035596bf3923a79d09 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 9c7f48607c..ad0d06a0ba 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -46,13 +46,13 @@ The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supporte [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all five targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the Windows target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 2e12a0495e..7bac8a35ed 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -46,13 +46,13 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 [`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签,或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 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-04-draft-provider-endpoint-interrogation.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml index 277670d745..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: d4112d813ad4f5781b74639209d13952e459f7dd -2026-08-04-draft-provider-endpoint-interrogation.zh.md: 1626a34cb3163949d70688cefeec77d328c62caa +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 d4112d813a..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 @@ -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` 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,16 +35,16 @@ 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. +**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. ## 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..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 @@ -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` 会应用[协议特定模型列表发现](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,16 +35,16 @@ pi-ai 提供了 `createProvider({ fetchModels })` 加上 `Models.refresh()` 与 **让 Host 读取整个已存 profile,而不是接受草稿。** 对已配置好的提供方来说,不会有机密跨越协议层。但这样一来新增提供方就必须先保存一份不可用的配置,而端点已改却尚未保存的表单会静默地去询问旧地址。草稿仍是端点和协议的权威来源。Host 侧的狭窄例外是只写的已存凭据,以及仍属部署配置、而非 Models 页面字段的 profile headers。 -**询问 pi-ai 的每一种协议。** Anthropic 的列表恰好与 OpenAI 共用同一层信封,而 Google 的不是。只支持容易的那几种会让覆盖范围变得任意;更糟的是,猜错的响应形状会与「该提供方没有模型」无法区分。一个明说自己无法被询问的协议,会把用户送去手工填写——那正是既定的回退路径。 +**询问每一种 pi-ai 协议。** 根据便利的响应相似性选择覆盖范围会显得武断,也会让猜错的响应与「提供方没有模型」无法区分。Anthropic 仅通过其文档化原生列表约定纳入支持,具体由[协议特定扩展](2026-09-02-protocol-specific-model-listing-discovery.zh.md)记录;Google 的字段集合与 Azure 的请求约定不同,Codex 则使用 OAuth。不支持的协议会把用户送去手工填写,这仍是既定回退路径。 **用 `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/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 3c8447434f..d6eca4702b 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: 66749e2e23e638243e9238e4fa7012199a425e97 -2026-08-22-single-dsh-application-launcher.zh.md: 6948a80b2c8c03755eec3bd97fd70d6d96cea213 +2026-08-22-single-dsh-application-launcher.md: 6fa08cfb2619ce6de4e30b958c367b50a0bad0c8 +2026-08-22-single-dsh-application-launcher.zh.md: 4e46bfb6e9160a361eef2176be1597fbe0837b15 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 66749e2e23..6fa08cfb26 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -48,7 +48,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. -The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this launch, and the [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth carrier. +The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this launch, and the [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the Windows carrier. ### Enforcement diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index 6948a80b2c..4e46bfb6e9 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -48,7 +48,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 -可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该启动方式,[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个载体。 +可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该启动方式,[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责 Windows 载体。 ### 强制校验 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index 30b0013c24..ec7b2c3e9f 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.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-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: 89aa19bfbf20ceb9a5b82cbca450df8057e28a81 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 9095831952656087dea6cbfb3f2158cf3e6623be +2026-08-23-python-sdk-dsh-profile-runtime.md: addeacb2be74f6210ec374a1e9bb18ec1a08cec1 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 576a5975997ed09413e6c5e22c8cb7450f206fb4 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index 89aa19bfbf..addeacb2be 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -34,7 +34,7 @@ The zero-code deployment manifest is `dsh-python-runtime-closure`. The build sta Plain Node profiles use symlinks in `$DSH_HOME/profiles/node_modules` to share installation packages with external plugins. An operating-system symlink cannot traverse pkg's `/snapshot` filesystem, so the packaged CLI writes small real ESM proxy packages instead. Each proxy resolves the source package's explicit ESM export map directly under Node import conditions, exposes targets that exist in the installation, and re-exports their virtual module URLs. Export rows without an ESM runtime target and executable-only or declaration-only packages produce no unusable proxy entry; malformed export maps fail startup. A complete matching generation returns without acquiring the cross-process writer lock. A missing or stale entry acquires the lock, rechecks the generation, and repairs it without exposing partial proxies; either carrier can replace the other carrier's managed entry. Loader rows and external plugin peers therefore resolve through the normal profile parent walk while retaining one Cordis and one instance of each bundled module. -The published target set is Linux x64, Linux arm64, macOS arm64, and Windows x64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth artifact and its platform-specific shell surface. +The published target set is Linux x64, Linux arm64, macOS arm64, macOS x64, and Windows x64. Installed-wheel black-box CI owns artifact provenance, default and patched profiles, external bundle installation, native tools, MCP, direct JSON-RPC, snapshots, and trusted real-provider turns on every target. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the Windows artifact and its platform-specific shell surface. ## Existing decisions and supersession diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 9095831952..576a597599 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -34,7 +34,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理直接按 Node import 条件解析源包的显式 ESM exports map,公开安装中实际存在的目标,并重新导出其虚拟模块 URL。没有 ESM 运行时目标的 export 项以及仅含可执行入口或类型声明入口的包不会产生不可用的代理条目;格式错误的 exports map 会导致启动失败。完整且匹配的 generation 不会获取跨进程写入锁。缺失或过期的配置项会获取该锁、重新检查 generation,并在不暴露半成品代理的前提下修复;任一载体都可以替换另一载体留下的受管配置项。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。 -已发布目标集合是 Linux x64、Linux arm64、macOS arm64 与 Windows x64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个产物及其平台专属 shell surface。 +已发布目标集合是 Linux x64、Linux arm64、macOS arm64、macOS x64 与 Windows x64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责 Windows 产物及其平台专属 shell surface。 ## 既有决策与取代关系 diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml index 3db66f3a7c..ba521bfc87 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.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-23-python-sdk-windows-x64-runtime.md -2026-08-23-python-sdk-windows-x64-runtime.md: 59a46d99f9e7ed411aeffbb541bbe3bb0c752078 -2026-08-23-python-sdk-windows-x64-runtime.zh.md: 3ab972aabb8135c8bc6285d129ba7bc9335eb11f +2026-08-23-python-sdk-windows-x64-runtime.md: 1b59dec47036e7351b212b408a31847a1a1c9639 +2026-08-23-python-sdk-windows-x64-runtime.zh.md: 75cea660c3e5cc8e0222e72dc2d07a0c802aeecd diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md index 59a46d99f9..1b59dec470 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.md @@ -20,7 +20,7 @@ The Python process still launches the ordinary `dsh --profile sdk` application a The executable builder accepts `win` as a pkg platform only with x64, requires the Windows build to run under x64 Node on a Windows host, preserves `.exe` names, and copies `@vscode/ripgrep-win32-x64` as the conventional `-rg.exe` sidecar. Pnpm subprocesses use a caller-supplied JavaScript entry through `process.execPath`. When the caller exposes a `.cmd` shim, the builder resolves the installed `pnpm.mjs` or `pnpm.cjs` through `PNPM_HOME`; it fails if no JavaScript entry exists instead of spawning the shim or enabling a command shell. -The required GitHub matrix builds `node24-win-x64` on `windows-2025` beside the three existing targets. The public GitHub release and GitLab tag pipeline each publish the same four runtime wheels plus the pure SDK wheel. Windows arm64 is absent from target parsing, manifests, matrices, release contents, and documentation. +The required GitHub matrix builds `node24-win-x64` on `windows-2025` beside Linux x64, Linux arm64, macOS arm64, and macOS x64. The public GitHub release and GitLab tag pipeline each publish the same five runtime wheels plus the pure SDK wheel. Windows arm64 is absent from target parsing, manifests, matrices, release contents, and documentation. ### Installed-wheel behavior @@ -48,4 +48,4 @@ This decision partially supersedes the Windows non-goal in the [single-file runt ## Consequences -Python installation now selects a Node-free Windows x64 runtime with the same explicit-home and profile customization model as Linux and macOS. Every pull request pays for a fourth executable, runtime wheel, full keyless blackbox, and—on trusted heads—real provider task. Release validation retains five wheels instead of four. Windows arm64 users receive an explicit unsupported-platform failure until a separate native product decision supplies and proves that carrier. +Python installation selects a Node-free Windows x64 runtime with the same explicit-home and profile customization model as Linux and macOS. Every pull request builds the Windows executable and runtime wheel as one of five native targets, runs the full keyless blackbox, and—on trusted heads—runs the real-provider task. Release validation retains six wheels. Windows arm64 users receive an explicit unsupported-platform failure until a separate native product decision supplies and proves that carrier. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md index 3ab972aabb..75cea660c3 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md @@ -20,7 +20,7 @@ Python 进程仍按 [Python profile 运行时决策](2026-08-23-python-sdk-dsh-p 可执行文件构建器仅允许 x64 使用 pkg 的 `win` 平台,并要求 Windows 构建在 Windows 宿主的 x64 Node 下运行;构建器保留 `.exe` 文件名,并把 `@vscode/ripgrep-win32-x64` 复制为常规 `-rg.exe` sidecar。Pnpm 子进程通过 `process.execPath` 执行调用方提供的 JavaScript 入口。当调用方暴露 `.cmd` shim 时,构建器会通过 `PNPM_HOME` 解析已安装的 `pnpm.mjs` 或 `pnpm.cjs`;如果不存在 JavaScript 入口,构建会失败,而不会启动 shim 或启用命令 shell。 -必需 GitHub 矩阵会在 `windows-2025` 上构建 `node24-win-x64`,与现有三个目标并列。公开 GitHub 发布与 GitLab 标签流水线都会发布同一组四个运行时 wheel 加纯 SDK wheel。目标解析、manifest、矩阵、发布内容与文档均不包含 Windows arm64。 +必需 GitHub 矩阵会在 `windows-2025` 上构建 `node24-win-x64`,与 Linux x64、Linux arm64、macOS arm64 和 macOS x64 并列。公开 GitHub 发布与 GitLab 标签流水线都会发布同一组五个运行时 wheel 加纯 SDK wheel。目标解析、manifest、矩阵、发布内容与文档均不包含 Windows arm64。 ### Installed-wheel 行为 @@ -48,4 +48,4 @@ Windows lane 会创建干净的 Windows 虚拟环境,安装版本精确匹配 ## Consequences -Python 安装现在会选择无需 Node 的 Windows x64 运行时,并与 Linux、macOS 使用同一套显式 home 与 profile 自定义模型。每个拉取请求都要承担第四个可执行文件、运行时 wheel 与完整 keyless 黑盒测试;可信 head 还要承担真实提供方任务。候选发行版验证会保留五个而不是四个 wheel。Windows arm64 用户会收到明确的不支持平台错误,直到另一项原生产品决策提供并证明该载体。 +Python 安装会选择无需 Node 的 Windows x64 运行时,并与 Linux、macOS 使用同一套显式 home 与 profile 自定义模型。每个拉取请求都会把 Windows 可执行文件和运行时 wheel 作为五个原生目标之一进行构建,运行完整 keyless 黑盒测试,并在可信 head 上运行真实提供方任务。候选发行版验证会保留六个 wheel。Windows arm64 用户会收到明确的不支持平台错误,直到另一项原生产品决策提供并证明该载体。 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..7676532850 --- /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: 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 new file mode 100644 index 0000000000..67927a9d14 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md @@ -0,0 +1,99 @@ +# 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 `$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 `$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. + +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. + +**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. + +`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. + +**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 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. + +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.** `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. + +**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. + +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. + +**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. + +## Alternatives considered + +**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. + +**`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 `$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. + +`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: 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 + +`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 `proxyRouteFor`, accepts an annotated exemption, and passes on the current tree. + +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 new file mode 100644 index 0000000000..cf0b203a06 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md @@ -0,0 +1,99 @@ +# 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/util/http-proxy` 解析出 `ProxyPolicy`,并在 `runProfile` 中于环境快照提供之后、任何 entry 挂载之前完成安装。Node 的 `fetch` 解析的正是 undici 的全局 dispatcher,因此每一处普通 `fetch()` 以及每一个最终落到 `globalThis.fetch` 的 SDK 都无需改动即被覆盖——撰写时是九个调用点,未来新增的也自动覆盖。`loadLayeredEnv` 只有一个调用方,且 `apps/web` 不提供 bin,因此这一处即覆盖全部 profile,包括不叠加 `base` 的 `sdk-minimal`。 + +解析读取的是启动器的快照而非 `process.env`,这正是让 `$DSH_HOME/.env` 中的代理生效的原因——也是环境变量方案不可能具备的能力。仅限该文件:`loadLayeredEnv` 拒绝项目 `.env` 里的代理名,正如它在那里拒绝 `PATH` 或 `NODE_OPTIONS`,因为那个文件随 clone 一起到来,不得替 Harness 选择路由。home 文件仅对这四个代理名豁免,而 `DSH_HOME` 本身是 bootstrap-only,因此没有任何 `.env` 能把这份豁免指向仓库控制的目录。 + +**放在 `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` 是那条配置分支唯一的供给方,别处无从到达。 + +**四个函数——收敛的是调用方,而不是让本包为每个 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 各一个。安装吸收了解析与诊断上报,因为没有调用方需要把它们分开:解析出来却不安装的策略什么也路由不了。 + +`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 本打算隧道转发的连接固定到某个地址上。 + +**解析补上 Node 与 undici 都不提供的部分。** `ALL_PROXY` 为两种协议兜底;空值视为未设置,因为 undici 的 `??` 链会让空的小写名遮住有值的大写名;loopback 始终绕过,否则 Web UI、Connection 传输以及每一个本地测试服务器都会经由代理并形成回环。绕过列表同时携带 `::1` **与** `[::1]`:undici 自带的匹配器会把裸写的 `::1` 读成主机 `:` 端口 `1`,从而永不豁免它。 + +**拒绝是静默的,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。环境是唯一来源,因此不存在一个本应适用 `AGENTS.md` 「配置错误必须响」规则的配置面。 + +**经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。 + +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` 调的是同一个函数。遥测则被有意保留为直连,而这个排除项才是更值得说的一半。 + +**遥测的直连是有意为之。** 要让它走代理只有两条路,代价都超过这条通道本身的价值。`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 确实能跑通,但代价是把传输层代码塞进了遥测插件。 + +与之相比,遥测是唯一一条丢失了对用户毫无代价的出网通道:没有任何工具、模型请求或会话依赖它,而连不上的导出本就被静默丢弃。处在强制代理后的用户,只是停留在本次改动之前的状态,而不是被弄坏。`egress.spec.ts` 现在断言这一排除——若某次 SDK 升级把导出器挪到 `fetch` 上,遥测就会开始静默走代理,而该用例正是让这件事暴露出来的东西。 + +**每个出网点都配一份出网测试,因为读代码不够。** 各所属包中的 `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` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。 + +## Alternatives considered + +**只写文档,让用户设 `NODE_USE_ENV_PROXY=1`。** 基于上文三条实测被否决:对 `$DSH_HOME/.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`、或把它写进 `$DSH_HOME/.env` 的用户,在 Harness 发起请求的每一处都会走代理,无需任何标志与配置。启动器在第一个插件挂载之前恰好安装一次。 + +由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `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 代理。破坏该耦合的版本升级会在那里失败,而不是流到线上。 + +测试套件对开发者自身的环境免疫:每份 Vitest 配置都会先运行 `scripts/test-proxy-environment.ts`,在任何测试之前清除全部八个代理变量名的两种大小写形式;`install.spec.ts` 则在每个自行设值的用例前后还原本机的值。这是必需的。开发过程中,一个已导出的小写 `all_proxy` 曾决定了某个测试的结果,因为解析优先读取小写。 + +## Testing + +`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` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。 + +出网测试以负向形式承载遥测这一项:随附后端在已安装策略下执行导出,而假代理什么也没收到——这个有意的排除因此是被断言的,而不只是被记录的。另有一组一致性测试,对文档所述 `NO_PROXY` 词汇中的每种形态,把 `proxyForUrl` 的判断与真实 `fetch` 的实际去向相互核对;由于 dispatcher 正是按同一谓词路由,它现在能抓住的是 `bypassesProxy` 对某种形态的读法与词汇文档不一致,以及未来任何重新引入第二个匹配器的 dispatcher。 + +无录制会话快照变更:本次改动不影响任何模型可见输入或产品用户可见的 transcript 输出。 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..4458589c77 --- /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: 69e684c284ff57da7ed61fd5dc417fd110d3e5b7 +2026-09-02-protocol-specific-model-listing-discovery.zh.md: 682806fb72c2cc24572e6e351d4d388cead84dc0 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..69e684c284 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.md @@ -0,0 +1,43 @@ +# 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, and gateway documentation publishes the API root both with and without that suffix. The listing URL therefore treats a drafted Anthropic `baseURL` ending in `/v1` as the same API root as the address without it, and it is the only place that segment is normalized: model requests receive the configured `baseURL` unchanged, exactly as pi-ai handles it. Deployment path prefixes remain intact: `https://gateway.example/tenant/v1` and `https://gateway.example/tenant` both list at `/tenant/v1/models`. + +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. + +**Normalize the inference base as well.** Stripping the same `/v1` segment before model routing would let a `/v1` address both list and serve, but it moves request URL rules out of pi-ai and into this package for one protocol. Model requests keep pi-ai's own handling of `baseURL`; the listing request is the only URL this package builds. + +**Refuse a trailing `/v1`.** A load-time or discovery-time rejection would name the mistake early, but gateway documentation publishes the `/v1` spelling, so a user pasting a documented address would be turned away from a listing that works. + +**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 list in either root or `/v1` form; model requests use the configured address as pi-ai receives it. + +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, the maximum-page query, and both spellings of the Anthropic root. Replies recorded from OpenRouter, models.dev, and DeepSeek on 2026-09-02, together with the example reply in Anthropic's List Models reference, live under `packages/llm/llm-pi-ai/tests/fixtures/model-listings/` and replay through the parser, so the accepted field spellings are pinned to real endpoints and the published reference rather than to hand-written samples. 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..682806fb72 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-02-protocol-specific-model-listing-discovery.zh.md @@ -0,0 +1,43 @@ +# 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`,而网关文档会同时发布带与不带该后缀的 API 根地址。因此列表 URL 会把末尾为 `/v1` 的 Anthropic `baseURL` 草稿视为与不带该后缀的地址相同的 API 根地址,并且只有它会归一化这一段:模型请求收到的仍是配置原样的 `baseURL`,与 pi-ai 的处理完全一致。部署路径前缀会保留:`https://gateway.example/tenant/v1` 与 `https://gateway.example/tenant` 都在 `/tenant/v1/models` 列表。 + +解析器接受 `data` 数组或富信息 `models` 对象,并在数组存在时优先使用它。数组条目使用自身的 `id`;对象条目使用属性键,因为嵌套 `id` 可能指向规范模型,而不是请求所接受的路由别名。只有值为对象的映射条目才视为模型,因此原始类型的目录元数据不会意外成为候选。空属性键才会回退到嵌套 `id`。 + +解析器会把受支持的名称与容量拼写归一化为 `LlmDiscoveredModel`。缺失的显示名会变成请求 id,使采纳操作填入完整的可编辑行。请求保留 Harness 归属标头;网关兼容性由响应解析提供,而非冒充客户端身份。 + +## 考虑过的替代方案 + +**跟随 Anthropic 的所有页面。** 游标遍历可以返回超过 1,000 个条目的列表,但会给配置操作增加多请求失败、取消、游标推进与总大小处理。实现请求 Anthropic 的最大页面,并记录剩余截断限制。 + +**同时归一化推理地址。** 在模型路由前截掉同一段 `/v1` 可以让 `/v1` 地址既能列表也能服务,但这会把请求 URL 规则从 pi-ai 挪进本包,且只为一种协议。模型请求保持 pi-ai 自身对 `baseURL` 的处理;列表请求是本包构造的唯一 URL。 + +**拒绝末尾的 `/v1`。** 在加载或探测时拒绝可以尽早点出错误,但网关文档发布的就是 `/v1` 写法,照文档粘贴地址的用户会被一个本能工作的列表拒之门外。 + +**为模型发现发送 OpenAI SDK `User-Agent`。** 这会让一个网关返回 `data`,但会错误标记 Harness 流量,并依赖未记录的客户端名称分支。读取两种已知响应格式可以保持归属准确。 + +**采纳 `models` 对象的每个属性。** 原始类型属性不能证明其键是模型 id,也可能是数量或状态等目录元数据。把条目限制为记录可避免虚构模型候选。 + +## 后果 + +Models 页面无需改变请求身份,即可询问 OpenAI 兼容网关与 Anthropic Messages 端点。发现的候选会在端点提供时携带路由 id、名称、上下文窗口与最大输出 token 数,只有 id 的列表也会通过 id 回退获得可编辑标签。Anthropic 地址以根地址或 `/v1` 形式都能列表;模型请求使用 pi-ai 收到的配置地址。 + +受支持格式仍是显式兼容集合,而不是任意 JSON 推断。可见模型超过 1,000 个的 Anthropic 账户需要手工录入第一页之外的条目,原始类型的 `models` 属性会被忽略。 + +## 测试 + +本地 HTTP 服务器测试钉住两种受支持响应格式、字段归一化、名称回退、忽略畸形条目、Anthropic 标头、最大页查询,以及 Anthropic 根地址的两种写法。2026-09-02 从 OpenRouter、models.dev 与 DeepSeek 录得的回复,连同 Anthropic List Models 参考文档给出的示例回复,存放在 `packages/llm/llm-pi-ai/tests/fixtures/model-listings/` 下并经解析器回放,因此受支持的字段拼写钉在真实端点与公开参考文档上,而不是手写样例上。 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-08-11-python-publication-workflow.i18n.yaml b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml index 91997d47b8..1bb6d9a73f 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.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-11-python-publication-workflow.md -2026-08-11-python-publication-workflow.md: 282bd453013da9b745c601f7b1f4be2cbd133629 -2026-08-11-python-publication-workflow.zh.md: 279dc4b5798d5ceb5968f92c58a7f57c4f2c5cdd +2026-08-11-python-publication-workflow.md: afb18ee97d0708d8a79885a8f6639681e97eef72 +2026-08-11-python-publication-workflow.zh.md: f2d045f686c41c3e45a3cd6739dc5e1b37c58478 diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md index 282bd45301..afb18ee97d 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.md @@ -6,15 +6,15 @@ English | [中文](2026-08-11-python-publication-workflow.zh.md) ## Problem -The Python SDK comprises one platform-independent client wheel and four native runtime wheels that must carry one version and become installable as a set. Public PyPI uploads expose package metadata and files immediately, cannot replace an uploaded filename, and create a temporarily unusable SDK if its exact runtime dependency has not arrived. The private repository needs to exercise the complete native build and validation sequence without publishing any artifact externally. +The Python SDK comprises one platform-independent client wheel and five native runtime wheels that must carry one version and become installable as a set. Public PyPI uploads expose package metadata and files immediately, cannot replace an uploaded filename, and create a temporarily unusable SDK if its exact runtime dependency has not arrived. The private repository needs to exercise the complete native build and validation sequence without publishing any artifact externally. ## Decision -The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all four platforms, installs the Linux release set on Python 3.10 and 3.14, downloads the five resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry run cannot enter either publication job. +The `Release (Python)` GitHub workflow exposes credential-free validation to manual runs with `publish=false`. The run calls the native wheel builder for all five targets, installs the Linux release set on Python 3.10 and 3.14, downloads the six resulting artifacts, verifies their exact filenames and package metadata, enforces PyPI's default per-file size limit, records SHA-256 hashes, and retains one aggregate release candidate. These jobs have only repository read permission and no registry credential or OIDC permission, and a dry run cannot enter either publication job. A run with `publish=true` must use the `python-v` tag in the private automation repository, match that repository's `github.repository` to its repository-scoped `PYPI_PUBLISHER_REPOSITORY` variable, find `PUBLIC_PYPI_RELEASE_ENABLED=true`, and receive approval from the `pypi-runtime` and `pypi` GitHub environments for runtime and SDK publication, respectively. The read-only public mirror supplies the package metadata URLs but does not run release Actions. Only the two publication jobs receive `id-token: write`; PyPI Trusted Publishing exchanges the private repository identity for short-lived project credentials, so the repository stores no PyPI token. -Publication consumes the aggregate artifact produced and checked in the same workflow run. Each publication job verifies the retained `SHA256SUMS` before selecting its upload set. A runtime job uploads all four platform wheels before a dependent job uploads the SDK wheel because PyPI uploads are not atomic and the SDK pins the runtime distribution at the exact same version. Neither job checks out source or rebuilds a wheel. Separating them lets GitHub's failed-job retry resume an SDK failure without attempting to replace immutable runtime files. +Publication consumes the aggregate artifact produced and checked in the same workflow run. Each publication job verifies the retained `SHA256SUMS` before selecting its upload set. A runtime job uploads all five platform wheels before a dependent job uploads the SDK wheel because PyPI uploads are not atomic and the SDK pins the runtime distribution at the exact same version. Neither job checks out source or rebuilds a wheel. Separating them lets GitHub's failed-job retry resume an SDK failure without attempting to replace immutable runtime files. Both publication actions disable public attestations. The action still uses Trusted Publishing for authentication, while omitting provenance that would disclose the private publisher repository instead of the public source mirror. diff --git a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md index 279dc4b579..f2d045f686 100644 --- a/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md +++ b/.agents/notes/implemented/process/2026-08-11-python-publication-workflow.zh.md @@ -6,15 +6,15 @@ Status: implemented ## 问题 -Python SDK 由一个平台无关的客户端 wheel 包和四个原生运行时 wheel 包组成,它们必须使用同一版本,并作为一组可安装。public PyPI 上传会立即公开包元数据和文件,无法替换已上传的同名文件;如果精确版本的运行时依赖尚未到达,还会产生暂时不可用的 SDK。私有仓库需要在不向外发布任何产物的情况下,执行完整的原生构建与验证流程。 +Python SDK 由一个平台无关的客户端 wheel 包和五个原生运行时 wheel 包组成,它们必须使用同一版本,并作为一组可安装。public PyPI 上传会立即公开包元数据和文件,无法替换已上传的同名文件;如果精确版本的运行时依赖尚未到达,还会产生暂时不可用的 SDK。私有仓库需要在不向外发布任何产物的情况下,执行完整的原生构建与验证流程。 ## 决策 -GitHub 的 `Release (Python)` 工作流为设置 `publish=false` 的手动运行提供无凭据验证。该运行会为全部四个平台调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得五份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,dry-run 运行无法进入任何发布作业。 +GitHub 的 `Release (Python)` 工作流为设置 `publish=false` 的手动运行提供无凭据验证。该运行会为全部五个目标调用原生 wheel 包构建器,在 Python 3.10 和 3.14 上安装 Linux 发行集合,下载所得六份产物,验证其精确文件名和包元数据,执行 PyPI 默认单文件大小限制,记录 SHA-256 哈希,并保留一份汇总候选发行版。这些作业只有仓库读取权限,没有注册表凭据或 OIDC 权限,dry-run 运行无法进入任何发布作业。 设置 `publish=true` 时,运行必须在私有自动化仓库使用 `python-v` 标签,将该仓库的 `github.repository` 与其仓库级 `PYPI_PUBLISHER_REPOSITORY` 变量匹配,找到 `PUBLIC_PYPI_RELEASE_ENABLED=true`,并分别获得 GitHub `pypi-runtime` 和 `pypi` 环境对运行时与 SDK 发布的批准。只读公开镜像提供包元数据 URL,但不运行发布 Actions。只有两个发布作业获得 `id-token: write`;PyPI Trusted Publishing 会把私有仓库身份换成短期项目凭据,因此仓库不保存 PyPI token。 -发布过程使用同一次工作流运行中生成并检查过的汇总产物。每个发布作业都会在选择上传文件前验证保留的 `SHA256SUMS`。一个运行时作业先上传全部四个平台 wheel 包,再由依赖它的作业上传 SDK wheel 包,因为 PyPI 上传不是原子操作,而 SDK 会把运行时分发包固定到完全相同的版本。两个作业都不会检出源码,也不会重新构建 wheel 包。将它们拆开后,GitHub 的失败作业重试可以在 SDK 上传失败时继续执行,而不会尝试替换不可变的运行时文件。 +发布过程使用同一次工作流运行中生成并检查过的汇总产物。每个发布作业都会在选择上传文件前验证保留的 `SHA256SUMS`。一个运行时作业先上传全部五个平台 wheel 包,再由依赖它的作业上传 SDK wheel 包,因为 PyPI 上传不是原子操作,而 SDK 会把运行时分发包固定到完全相同的版本。两个作业都不会检出源码,也不会重新构建 wheel 包。将它们拆开后,GitHub 的失败作业重试可以在 SDK 上传失败时继续执行,而不会尝试替换不可变的运行时文件。 两个发布 action 都会禁用公开 attestation。action 仍使用 Trusted Publishing 进行身份认证,同时不上传会披露私有发布仓库而非公开源码镜像的 provenance。 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..305f975303 --- /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: e44fe0f002c40309beaeb4af3ccdd975714777e6 +2026-09-02-project-local-issue-planning-fields.zh.md: 28308d48994dbb144f7e7934a1058cef35942c20 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..e44fe0f002 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.md @@ -0,0 +1,43 @@ +# 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 pull-request policy workflow uses the repository `GITHUB_TOKEN` for REST Issue and pull-request reads, and a GitHub App token restricted to repository Issues and 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, 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 + +**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..28308d4899 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-02-project-local-issue-planning-fields.zh.md @@ -0,0 +1,43 @@ +# 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 ...` 前缀的迁移源保留,仓库工作流不会读取它们。 + +PR 策略工作流使用仓库 `GITHUB_TOKEN` 执行 REST Issue 和 PR 读取,并使用仅有仓库 Issues 与组织 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,证明仓库读取与 Project 读取使用不同凭据,覆盖上海时区日期边界、仅 opened 分派、空值写入、已有值保留和 Project item 缺失,并固定 `updateProjectV2ItemFieldValue`。工作流测试固定 Project token 的只读权限。删除组织字段前必须逐项比较所有旧字段值与 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/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml index 03c1a43a28..2e755b0648 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.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/testing/2026-08-23-installed-python-wheel-black-box-ci.md -2026-08-23-installed-python-wheel-black-box-ci.md: 0ac3bc63ef391536a761ad6db9d0854a3beebe01 -2026-08-23-installed-python-wheel-black-box-ci.zh.md: 365da458d3eb33dbc82dcdafaebea593cc4fe971 +2026-08-23-installed-python-wheel-black-box-ci.md: a1c5d5f0a8040747caa2148f6b4990fd58828cbe +2026-08-23-installed-python-wheel-black-box-ci.zh.md: ab2b6c11a2aa49a78bad990b780019f704edef51 diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md index 0ac3bc63ef..a1c5d5f0a8 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md @@ -30,7 +30,7 @@ Fork and Dependabot pull requests never receive the repository secret. Their nat ### Required targets -The pull-request `python-runtime` job calls the reusable builder for Linux x64, Linux arm64, macOS arm64, and Windows x64. Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. The [Windows x64 runtime decision](../architecture/2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and its PowerShell-specific minimal snapshot. +The pull-request `python-runtime` job calls the reusable builder for Linux x64, Linux arm64, macOS arm64, macOS x64, and Windows x64. Its aggregate result remains a dependency of `all checks passed`, so a failed, cancelled, or missing native carrier blocks the required verdict. The [Windows x64 runtime decision](../architecture/2026-08-23-python-sdk-windows-x64-runtime.md) owns the Windows target and its PowerShell-specific minimal snapshot. ## Existing decisions and supersession @@ -38,7 +38,7 @@ This decision supersedes the single-target topology in the archived [required Py ## Alternatives considered -**Keep Linux x64 as the only required carrier.** Rejected because native addons, executable construction, wheel tags, and helper files differ across the four published targets. Release-time discovery is too late for an artifact that every Python SDK installation selects by platform. +**Keep Linux x64 as the only required carrier.** Rejected because native addons, executable construction, wheel tags, and helper files differ across the five published targets. Release-time discovery is too late for an artifact that every Python SDK installation selects by platform. **Run full behavior before wheel construction and keep two small installed smokes.** Rejected because that proves the executable against source imports, then proves too little through the distribution users install. The clean installed environment is the stronger common location for the same scenarios. @@ -48,4 +48,4 @@ This decision supersedes the single-target topology in the archived [required Py ## Consequences -Every pull request pays for four native executable and wheel builds plus deterministic installed-artifact scenarios. Trusted same-repository pull requests also pay for one two-turn DeepSeek task per target. In exchange, the required result describes the files Python users install, proves every published carrier before merge, and cannot pass by importing the checkout or silently skipping the real provider. +Every pull request pays for five native executable and wheel builds plus deterministic installed-artifact scenarios. Trusted same-repository pull requests also pay for one two-turn DeepSeek task per target. In exchange, the required result describes the files Python users install, proves every published carrier before merge, and cannot pass by importing the checkout or silently skipping the real provider. diff --git a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md index 365da458d3..ab2b6c11a2 100644 --- a/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md +++ b/.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md @@ -30,7 +30,7 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ### 必需目标 -拉取请求的 `python-runtime` job 会针对 Linux x64、Linux arm64、macOS arm64 与 Windows x64 调用可复用构建器。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。[Windows x64 运行时决策](../architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及其 PowerShell 专属极简快照。 +拉取请求的 `python-runtime` job 会针对 Linux x64、Linux arm64、macOS arm64、macOS x64 与 Windows x64 调用可复用构建器。其聚合结果仍是 `all checks passed` 的依赖项,因此任一原生载体失败、取消或缺失都会阻止必需判定通过。[Windows x64 运行时决策](../architecture/2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责 Windows 目标及其 PowerShell 专属极简快照。 ## Existing decisions and supersession @@ -38,7 +38,7 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ## Alternatives considered -**只保留 Linux x64 必需载体。** 否决:四个已发布目标的原生 addon、可执行文件构建、wheel 包标签与 helper 文件不同。等到发布时才发现问题,对每个 Python SDK 安装都会按平台选择的产物而言太晚。 +**只保留 Linux x64 必需载体。** 否决:五个已发布目标的原生 addon、可执行文件构建、wheel 包标签与 helper 文件不同。等到发布时才发现问题,对每个 Python SDK 安装都会按平台选择的产物而言太晚。 **在 wheel 构建前运行完整行为,并保留两个很小的安装后冒烟测试。** 否决:这只能证明可执行文件配合源码 import 工作,再通过 distribution 证明很少的行为。干净安装环境是在同一批场景中验证用户实际安装内容的更强位置。 @@ -48,4 +48,4 @@ Fork 与 Dependabot 拉取请求永远不会获得仓库密钥。它们的原生 ## Consequences -每个拉取请求都会承担四个原生可执行文件及 wheel 包构建,并运行确定性的安装后产物场景。可信的同仓库拉取请求还会在每个目标上承担一次双轮 DeepSeek 任务。相应地,必需结果描述 Python 用户实际安装的文件,在合并前证明每个已发布载体,并且不能通过导入 checkout 或静默跳过真实提供方而通过。 +每个拉取请求都会承担五个原生可执行文件及 wheel 包构建,并运行确定性的安装后产物场景。可信的同仓库拉取请求还会在每个目标上承担一次双轮 DeepSeek 任务。相应地,必需结果描述 Python 用户实际安装的文件,在合并前证明每个已发布载体,并且不能通过导入 checkout 或静默跳过真实提供方而通过。 diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index ef5583894f..6b731f1318 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 未设置') } @@ -415,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, @@ -439,19 +446,25 @@ 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 } -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 +473,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 +489,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa $project: Int! $includeStatusActor: Boolean! $includeStartDate: Boolean! + $priorityField: String! $startDateField: String! ) { organization(login: $organization) { @@ -484,8 +498,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 +535,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 +554,7 @@ async function projectContext(number, includeStatusActor = false, includeStartDa project: config.projectNumber, includeStatusActor, includeStartDate, + priorityField: config.priorityField, startDateField: config.startDateField, }, ) @@ -535,6 +564,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 +592,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 +611,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..e2d01a5c78 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', @@ -29,7 +34,24 @@ 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 + ? [ + { + id: 'priority-project-field-id', + name: 'Priority', + dataType: priorityType, + isIssueField: priorityIsIssueField, + options: [], + }, + ] + : []), ...(startDateField ? [ { @@ -54,6 +76,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 +290,52 @@ 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 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 (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', + 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 project-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 +347,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 +404,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], diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 8f500caa86..c00241b0e0 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -2,7 +2,7 @@ name: Build single-exe # Native builds for the release targets; see # .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md. -# A full target run retains one SDK wheel and four runtime wheels; subset +# A full target run retains one SDK wheel and five runtime wheels; subset # dispatch retains the SDK wheel and selected runtime wheels. Bare executables # and source closures are test inputs. Run manually or call it from the Python # release workflow. There is no `pull_request` trigger: a label trigger would @@ -12,7 +12,7 @@ on: workflow_call: inputs: targets: - description: Comma-separated pkg targets to build; empty builds all four. + description: Comma-separated pkg targets to build; empty builds all five. type: string required: false default: '' @@ -36,7 +36,7 @@ on: description: >- Comma-separated pkg targets to build. Any subset of: node24-linux-x64, node24-linux-arm64, node24-macos-arm64, - node24-win-x64. Empty builds all four. + node24-macos-x64, node24-win-x64. Empty builds all five. type: string required: false default: '' @@ -89,7 +89,7 @@ jobs: id: plan env: # Blank dispatch inputs build all targets. - TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64' }} + TARGETS: ${{ inputs.targets || 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64' }} run: | set -euo pipefail matrix='[]' @@ -98,14 +98,15 @@ jobs: t="$(echo "$raw" | xargs)" # trim surrounding whitespace [ -z "$t" ] && continue # Native-only: hosted arm64 Linux uses ubuntu-24.04-arm, while - # macos-latest is Apple Silicon. + # macos-latest is Apple Silicon; macos-15-intel is native x64. case "$t" in node24-linux-x64) runner=ubuntu-latest ;; node24-linux-arm64) runner=ubuntu-24.04-arm ;; node24-macos-arm64) runner=macos-latest ;; + node24-macos-x64) runner=macos-15-intel ;; node24-win-x64) runner=windows-2025 ;; *) - echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64, node24-win-x64." + echo "::error::Unknown target '$t'. Supported: node24-linux-x64, node24-linux-arm64, node24-macos-arm64, node24-macos-x64, node24-win-x64." exit 1 ;; esac @@ -251,6 +252,7 @@ jobs: linux-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl ;; linux-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl ;; macos-arm64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl ;; + macos-x64) wheel=deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_x86_64.whl ;; *) echo "::error::Unsupported runtime platform $platform"; exit 1 ;; esac [ -x "$exe" ] || { echo "::error::$exe missing or not executable"; exit 1; } @@ -432,13 +434,23 @@ jobs: exit 1 } - - name: Check macOS deployment target + - name: Check macOS payload architecture and deployment target if: runner.os == 'macOS' env: EXE: ${{ steps.runtime-posix.outputs.exe }} - run: >- - python3 scripts/check-macos-deployment-target.py - "$EXE" "$EXE-spawn-helper" + PLATFORM: ${{ steps.runtime-posix.outputs.platform }} + run: | + set -euo pipefail + case "$PLATFORM" in + macos-arm64) macho_arch=arm64 ;; + macos-x64) macho_arch=x86_64 ;; + *) echo "::error::Unsupported macOS platform $PLATFORM"; exit 1 ;; + esac + for payload in "$EXE" "$EXE-rg" "$EXE-spawn-helper"; do + lipo "$payload" -verify_arch "$macho_arch" + done + python3 scripts/check-macos-deployment-target.py \ + --platform "$PLATFORM" "$EXE" "$EXE-rg" "$EXE-spawn-helper" - name: Run wheel in a manylinux 2.28 container if: runner.os == 'Linux' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32c81a257b..7797fa9f7b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,7 +328,7 @@ jobs: name: python runtime / release-shaped matrix uses: ./.github/workflows/build-exe-for-python-sdk.yml with: - targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64 + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64 ci: true secrets: DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }} diff --git a/.github/workflows/issue-policy.yml b/.github/workflows/issue-policy.yml index dde9462c33..9428569632 100644 --- a/.github/workflows/issue-policy.yml +++ b/.github/workflows/issue-policy.yml @@ -21,7 +21,20 @@ jobs: with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false + - name: Create Project read token + id: app-token + if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }} + 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-issues: read + permission-organization-projects: read - name: Validate pull request + if: ${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }} env: GITHUB_TOKEN: ${{ github.token }} + PROJECT_TOKEN: ${{ steps.app-token.outputs.token }} run: node .github/issue-management/policy.mjs pr diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 18524523b9..f3d9ab44c3 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -24,10 +24,10 @@ concurrency: jobs: build: - name: Build five wheels + name: Build six wheels uses: ./.github/workflows/build-exe-for-python-sdk.yml with: - targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64 + targets: node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64 release: true python-compat: @@ -149,6 +149,7 @@ jobs: actual="$(mktemp)" printf '%s\n' \ "deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_arm64.whl" \ + "deepseek_harness_runtime_bin-$VERSION-py3-none-macosx_14_0_x86_64.whl" \ "deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_aarch64.whl" \ "deepseek_harness_runtime_bin-$VERSION-py3-none-manylinux_2_28_x86_64.whl" \ "deepseek_harness_runtime_bin-$VERSION-py3-none-win_amd64.whl" \ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 008200560c..f970626f79 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -60,8 +60,16 @@ sdk-wheel: docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_WHEEL_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-mcp" fi - | - if [ "$PLATFORM" = macos-arm64 ]; then - python3 scripts/check-macos-deployment-target.py "$EXE" "$EXE-spawn-helper" + if [ "${PLATFORM#macos-}" != "$PLATFORM" ]; then + case "$PLATFORM" in + macos-arm64) macho_arch=arm64 ;; + macos-x64) macho_arch=x86_64 ;; + *) echo "Unsupported macOS platform $PLATFORM"; exit 1 ;; + esac + for payload in "$EXE" "$EXE-rg" "$EXE-spawn-helper"; do + lipo "$payload" -verify_arch "$macho_arch" + done + python3 scripts/check-macos-deployment-target.py --platform "$PLATFORM" "$EXE" "$EXE-rg" "$EXE-spawn-helper" fi artifacts: paths: [release/$PLATFORM/*.whl] @@ -97,6 +105,16 @@ runtime-macos-arm64: - job: sdk-wheel artifacts: true +runtime-macos-x64: + extends: .runtime-wheel + tags: [macos-x64] + variables: + PKG_TARGET: node24-macos-x64 + PLATFORM: macos-x64 + needs: + - job: sdk-wheel + artifacts: true + runtime-windows-x64: stage: build tags: [windows-x64] @@ -147,6 +165,8 @@ publish-python: artifacts: true - job: runtime-macos-arm64 artifacts: true + - job: runtime-macos-x64 + artifacts: true - job: runtime-windows-x64 artifacts: true before_script: @@ -157,11 +177,12 @@ publish-python: - test "$CI_COMMIT_TAG" = "python-v$DSH_VERSION" || { echo "Tag $CI_COMMIT_TAG does not match package.json version $DSH_VERSION"; exit 1; } - python -m pip install twine==6.2.0 script: - - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 5 + - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 6 - test -f "release/sdk/deepseek_harness_sdk-${DSH_WHEEL_VERSION}-py3-none-any.whl" - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-manylinux_2_28_x86_64.whl" - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-manylinux_2_28_aarch64.whl" - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-macosx_14_0_arm64.whl" + - test -f "release/macos-x64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-macosx_14_0_x86_64.whl" - test -f "release/win-x64/deepseek_harness_runtime_bin-${DSH_WHEEL_VERSION}-py3-none-win_amd64.whl" - python -m twine check release/*/*.whl - export TWINE_USERNAME=gitlab-ci-token diff --git a/apps/cli/package.json b/apps/cli/package.json index 9a17e1dcfb..7a389053d4 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -97,7 +97,8 @@ "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0", "js-yaml": "^4.2.0", - "node-addon-require-builtin": "^0.1.4" + "node-addon-require-builtin": "^0.1.4", + "@deepseek-ai/dsh-http-proxy": "workspace:^" }, "devDependencies": { "@agentclientprotocol/sdk": "1.4.0", diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 125240f60c..37aef6b7ae 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: b43c147036ad230ed85cebafa3df89d67a802f6b -README.zh.md: 9191139e28b4bb449593514cb61750de42dba24e +README.md: 78328be4d614ebf647d6f00a6f0d66978919da3e +README.zh.md: c140c3ba528509611ae17712851c6d406c190972 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index b43c147036..78328be4d6 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 9191139e28..c140c3ba52 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..1b12218ab5 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 { 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' @@ -207,10 +208,22 @@ 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 disposeProxy = await installProxyFromEnvironment( + options.environment, + (message) => { process.stderr.write(`${NAME}: ${message}\n`) }, + ) + 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/apps/web/package.json b/apps/web/package.json index 633c223758..a9c1e12c98 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, 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/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 1c81bb04ef..7279b6fa2e 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: 9e8c44cb8cb19066dcb815dcb98da6e7bcfb0ff4 -config-catalog.zh.md: 76f4f5f8c333ba4a254a41067f29e0eac2317df8 +config-catalog.md: 31642949ed6a3c501b34ef59506cabbc1d06506e +config-catalog.zh.md: 0ed519d5e050432518cb2afad051dbe399706ee6 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9e8c44cb8c..31642949ed 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -513,7 +513,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:45`](../packages/e2b/e2b/src/index.ts) @@ -3454,6 +3454,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 76f4f5f8c3..0ed519d5e0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -515,7 +515,7 @@ export interface Config { } ``` -来源:[`packages/e2b/e2b/src/index.ts:43`](../packages/e2b/e2b/src/index.ts) +来源:[`packages/e2b/e2b/src/index.ts:45`](../packages/e2b/e2b/src/index.ts) @@ -3455,6 +3455,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 7a1a91a45a..303dd0f885 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: 514d09c5883ec8196e0706fbd7a3ccf45b8aafb5 -module-graph.zh.md: 444100e17ce6794efa39ef2e0a10872a6e1c3484 +module-graph.md: e6ca3e11a28b70310ecbd3118e1c736227626abf +module-graph.zh.md: 9025dc6cce64d89cceaded2e92e3e5b465f79c7a diff --git a/docs/module-graph.md b/docs/module-graph.md index 514d09c588..e6ca3e11a2 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"] @@ -360,9 +361,7 @@ flowchart TD pkg_web --> pkg_llm pkg_attachment --> pkg_brand pkg_credentials --> pkg_invariants - pkg_subprocess_e2b --> pkg_e2b - pkg_subprocess_e2b --> pkg_subprocess - pkg_subprocess_e2b --> pkg_timeout + pkg_e2b --> pkg_http_proxy pkg_experimental_code_runtime_python --> pkg_code_runtime pkg_experimental_code_runtime_python --> pkg_timeout pkg_experimental_code_runtime_python --> pkg_util_values @@ -386,8 +385,7 @@ flowchart TD pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage - pkg_subprocess_local --> pkg_subprocess - pkg_subprocess_local --> pkg_timeout + pkg_subprocess --> pkg_http_proxy pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope pkg_system_prompt --> pkg_invariants @@ -395,6 +393,7 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_skill --> pkg_llm pkg_skill --> pkg_scope + pkg_web_fetch_http --> pkg_http_proxy pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web pkg_web_search_exa --> pkg_launch_environment @@ -411,6 +410,11 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_launch_environment + pkg_subprocess_e2b --> pkg_e2b + pkg_subprocess_e2b --> pkg_subprocess + pkg_subprocess_e2b --> pkg_timeout + pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_skill_badge --> pkg_skill pkg_spill --> pkg_brand pkg_spill --> pkg_llm @@ -434,6 +438,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_session pkg_agent --> pkg_invariants pkg_agent --> pkg_llm @@ -603,6 +608,7 @@ flowchart TD pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand pkg_loader_smoke --> pkg_agent + pkg_loader_smoke --> pkg_http_proxy pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session pkg_workflow --> pkg_agent @@ -1142,6 +1148,7 @@ flowchart TD | [`brand`](../packages/util/brand) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | +| [`http-proxy`](../packages/util/http-proxy) | `util` | — | | [`launch-environment`](../packages/util/launch-environment) | `util` | — | | [`native-command`](../packages/util/native-command) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | @@ -1203,7 +1210,6 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | -| [`e2b`](../packages/e2b/e2b) | `e2b` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1216,7 +1222,6 @@ flowchart TD | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — | | [`storage`](../packages/storage/storage) | `storage` | — | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | @@ -1226,7 +1231,7 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`e2b`](../packages/e2b/e2b) | `e2b` | [`http-proxy`](../packages/util/http-proxy) | | [`experimental-code-runtime-python`](../packages/experimental/code-runtime-python) | `experimental` | [`code-runtime`](../packages/code-runtime/code-runtime), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | @@ -1237,18 +1242,20 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`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` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`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), [`launch-environment`](../packages/util/launch-environment) | +| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | @@ -1259,7 +1266,7 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`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` | [`session`](../packages/core/session) | +| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`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` | [`spill`](../packages/spill/spill) | @@ -1299,7 +1306,7 @@ flowchart TD | [`bash-local`](../packages/shell/bash-local) | `shell` | [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`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) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`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), [`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), [`llm`](../packages/llm/llm) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 444100e17c..9025dc6cce 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"] @@ -362,9 +363,7 @@ flowchart TD pkg_web --> pkg_llm pkg_attachment --> pkg_brand pkg_credentials --> pkg_invariants - pkg_subprocess_e2b --> pkg_e2b - pkg_subprocess_e2b --> pkg_subprocess - pkg_subprocess_e2b --> pkg_timeout + pkg_e2b --> pkg_http_proxy pkg_experimental_code_runtime_python --> pkg_code_runtime pkg_experimental_code_runtime_python --> pkg_timeout pkg_experimental_code_runtime_python --> pkg_util_values @@ -388,8 +387,7 @@ flowchart TD pkg_storage_domain --> pkg_storage pkg_storage_json --> pkg_storage pkg_storage_sqlite --> pkg_storage - pkg_subprocess_local --> pkg_subprocess - pkg_subprocess_local --> pkg_timeout + pkg_subprocess --> pkg_http_proxy pkg_typert_loader --> pkg_typert_registry pkg_session --> pkg_scope pkg_system_prompt --> pkg_invariants @@ -397,6 +395,7 @@ flowchart TD pkg_system_prompt --> pkg_scope pkg_skill --> pkg_llm pkg_skill --> pkg_scope + pkg_web_fetch_http --> pkg_http_proxy pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web pkg_web_search_exa --> pkg_launch_environment @@ -413,6 +412,11 @@ flowchart TD pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_launch_environment + pkg_subprocess_e2b --> pkg_e2b + pkg_subprocess_e2b --> pkg_subprocess + pkg_subprocess_e2b --> pkg_timeout + pkg_subprocess_local --> pkg_subprocess + pkg_subprocess_local --> pkg_timeout pkg_skill_badge --> pkg_skill pkg_spill --> pkg_brand pkg_spill --> pkg_llm @@ -436,6 +440,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_session pkg_agent --> pkg_invariants pkg_agent --> pkg_llm @@ -605,6 +610,7 @@ flowchart TD pkg_terminal --> pkg_agent pkg_terminal --> pkg_brand pkg_loader_smoke --> pkg_agent + pkg_loader_smoke --> pkg_http_proxy pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session pkg_workflow --> pkg_agent @@ -1144,6 +1150,7 @@ flowchart TD | [`brand`](../packages/util/brand) | `util` | — | | [`deque`](../packages/util/deque) | `util` | — | | [`home-paths`](../packages/util/home-paths) | `util` | — | +| [`http-proxy`](../packages/util/http-proxy) | `util` | — | | [`launch-environment`](../packages/util/launch-environment) | `util` | — | | [`native-command`](../packages/util/native-command) | `util` | — | | [`output-retention`](../packages/util/output-retention) | `util` | — | @@ -1205,7 +1212,6 @@ flowchart TD | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | — | | [`client-web`](../packages/client/web) | `client` | — | | [`code-runtime`](../packages/code-runtime/code-runtime) | `code-runtime` | — | -| [`e2b`](../packages/e2b/e2b) | `e2b` | — | | [`experimental-agent-team-profile`](../packages/experimental/agent-team-profile) | `experimental` | — | | [`experimental-agent-team-web-profile`](../packages/experimental/agent-team-web-profile) | `experimental` | — | | [`experimental-webworker-packer`](../packages/experimental/webworker-packer) | `experimental` | — | @@ -1218,7 +1224,6 @@ flowchart TD | [`invariants`](../packages/runtime-diagnostics/invariants) | `runtime-diagnostics` | — | | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | — | | [`storage`](../packages/storage/storage) | `storage` | — | -| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | — | | [`win32-process`](../packages/subprocess/win32-process) | `subprocess` | — | | [`llm-mock-server`](../packages/test-support/llm-mock-server) | `test-support` | — | | [`typert-generator`](../packages/typert/generator) | `typert` | — | @@ -1228,7 +1233,7 @@ flowchart TD | [`web`](../packages/web/web) | `web` | [`llm`](../packages/llm/llm) | | [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand) | | [`credentials`](../packages/credentials/credentials) | `credentials` | [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`e2b`](../packages/e2b/e2b) | `e2b` | [`http-proxy`](../packages/util/http-proxy) | | [`experimental-code-runtime-python`](../packages/experimental/code-runtime-python) | `experimental` | [`code-runtime`](../packages/code-runtime/code-runtime), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | | [`experimental-webworker-runtime`](../packages/experimental/webworker-runtime) | `experimental` | [`client-connection`](../packages/client/connection), [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver) | @@ -1239,18 +1244,20 @@ flowchart TD | [`storage-domain`](../packages/storage/storage-domain) | `storage` | [`invariants`](../packages/runtime-diagnostics/invariants), [`storage`](../packages/storage/storage) | | [`storage-json`](../packages/storage/storage-json) | `storage` | [`storage`](../packages/storage/storage) | | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`storage`](../packages/storage/storage) | -| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subprocess`](../packages/subprocess/subprocess) | `subprocess` | [`http-proxy`](../packages/util/http-proxy) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`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` | [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`api-remotes`](../packages/api/remotes) | `api` | [`scope`](../packages/core/scope) | | [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`home-paths`](../packages/util/home-paths) | | [`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), [`launch-environment`](../packages/util/launch-environment) | +| [`subprocess-e2b`](../packages/e2b/subprocess-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | +| [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment), [`system-prompt`](../packages/core/system-prompt) | @@ -1261,7 +1268,7 @@ flowchart TD | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`session-projection`](../packages/session/session-projection) | `session` | [`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` | [`session`](../packages/core/session) | +| [`session-snapshot`](../packages/test-support/session-snapshot) | `test-support` | [`http-proxy`](../packages/util/http-proxy), [`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` | [`spill`](../packages/spill/spill) | @@ -1301,7 +1308,7 @@ flowchart TD | [`bash-local`](../packages/shell/bash-local) | `shell` | [`settings`](../packages/settings/settings), [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`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) | -| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`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), [`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), [`llm`](../packages/llm/llm) | diff --git a/docs/user/guide/network-proxy.i18n.yaml b/docs/user/guide/network-proxy.i18n.yaml new file mode 100644 index 0000000000..92e08b46e0 --- /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: d53d48688490c741f0ed7f950c5ff39db02dc1e9 +network-proxy.zh.md: 1eee1e67abb700e15f3b2cea1b22bf036c694302 diff --git a/docs/user/guide/network-proxy.md b/docs/user/guide/network-proxy.md new file mode 100644 index 0000000000..d53d486884 --- /dev/null +++ b/docs/user/guide/network-proxy.md @@ -0,0 +1,85 @@ +# Run DSH behind a network proxy + +English | [中文](network-proxy.zh.md) + +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 + +```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, 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. + +## 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 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. + +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 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. + +**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. 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. + +## 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. +- **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 + +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..1eee1e67ab --- /dev/null +++ b/docs/user/guide/network-proxy.zh.md @@ -0,0 +1,85 @@ +# 在网络代理后面运行 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_HOME/.env`(默认 `~/.dsh/.env`),和 API key 放在一起;导出的环境变量始终优先于该文件。项目自己的 `.env` 不能设置它们:它随 `git clone` 一起到来,DSH 宁可拒绝启动,也不让一个仓库决定你的流量去向。 + +需要凭据的代理把凭据写在 URL 里:`http://user:password@proxy.example:8080`。DSH 绝不会回显这个 URL:诊断只点名被拒绝的变量,因此用户名和密码都不会出现在任何地方。 + +## 为什么浏览器走代理、终端却不走 + +这是最常见的意外,而且并非 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 +``` + +一个条目写的是主机名,它连同其下所有子域名一起匹配:`NO_PROXY=example.com` 也会让 `api.example.com` 直连。前缀 `.` 或 `*.` 可以写,含义相同。条目可带 `: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://` 形式的值会在启动时被报告并跳过,指定它的那个 scheme 转为直连——把 `HTTPS_PROXY=socks5://…` 与一个可用的 `HTTP_PROXY` 一起设置时,`https:` 会保持直连,而不会去借用 HTTP 代理。请把变量指向代理软件的 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 会直连。如果你的某个代理变量是 DSH 拒绝的值——比如 SOCKS URL——基于 Node 的工具同样直连而不是起不来,`curl` 与 `git` 则仍会读取那个值。 + +**代理 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。这类脚本只有自行配置才能联网。 +- **使用情况遥测。** OTLP 导出器用的是 Node 自带的 HTTP 客户端,而不是代理所配置的那个,因此遥测直连;在禁止直连出网的环境里它只会失败。DSH 的任何功能都不依赖它。设 `DSH_TELEMETRY_MODE=DISABLED` 可完全关闭。 +- **`web_fetch` 访问字面量私网地址。** 形如 `http://10.0.0.5/` 的 URL 会被拒绝而非交给代理,与未配置代理时得到的拒绝相同。 + +## 验证是否生效 + +让 agent 抓取一个页面,同时观察代理软件的连接日志: + +```sh +dsh --profile headless "fetch https://example.com and tell me the page title" +``` + +如果请求没有出现在那里,确认变量确实进入了 DSH 自己的环境: + +```sh +env | grep -i proxy +``` diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 392f88f256..46b27e23a4 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.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/providers.md -providers.md: 59682d38c71893c16118639cb2b24f63497a3c13 -providers.zh.md: a72be6c706f7ce6d29a802bfaa7f65eedb2eb991 +providers.md: 0db3280760c487c8469399bc9668b0685213e375 +providers.zh.md: 3af9096f5a77ee40afe2ee788aa22a0758dd0e86 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 59682d38c7..0db3280760 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -12,21 +12,39 @@ Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference. -## Add a catalog provider +## Add a built-in provider -Choose **Add provider**, select a provider such as Anthropic or OpenAI, enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list. +Choose **Add provider** and pick a provider dsh ships with; the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM. Enter its API key and save. The installed catalog supplies the endpoint, protocol, and model list. -Providers with native authentication need their native credentials instead. Bedrock, Vertex, Azure, and Codex use AWS credentials and a region, an ADC project, an `api-version`, and OAuth respectively; filling only the API-key field does not configure them. +Providers that sign in with OAuth, such as Codex, are not supported here yet. ## Add a custom provider -Choose **Add a custom provider** for a company gateway, self-hosted server, or provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. +Choose **Add a custom provider** for a company gateway, self-hosted server, or provider absent from the installed catalog. Supply a lowercase Provider ID, base URL, API protocol, credential, and at least one model. The **API protocol** must be the one your gateway speaks, and the form offers three: `openai-completions` for OpenAI Chat Completions, `openai-responses` for the OpenAI Responses API, and `anthropic-messages` for the Anthropic Messages API. A provider speaks one protocol, so a gateway that serves two needs two providers. ![The custom provider form: Provider ID, display name, base URL, API protocol, and API key](providers-custom-form.png) The Provider ID is permanent because requests, saved sessions, model defaults, and credential references use it. To rename a provider, add a new provider and delete the old one. The display name, base URL, protocol, credential, and models remain editable. -Under **Model catalog**, choose **Fetch available models** to query the base URL and credential currently shown in the form. Selecting candidates updates the draft; the provider is not stored until you save. Catalog providers use their installed catalog without a network request. +### Discover models + +Under **Model catalog**, choose **Fetch available models** to ask the endpoint which models it serves. The request uses the base URL, protocol, and key currently in the form, or a saved provider's stored key, and the reply opens a searchable picker: search, tick the models you want, and choose **Add selected**. Nothing is stored until you save or create the provider. + +Discovery reads the listing formats common gateways publish, but not every endpoint answers in one of them, so treat it as a convenience rather than a guarantee: when it fails or lists nothing, add the model ids by hand and they work just the same. A built-in provider is always answered from the installed catalog, even when its base URL points at a gateway, so fetch through a custom provider to see what the gateway really serves. + +## Select a model + +Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log. + +If a saved default names a provider that was deleted, the composer displays **Select model** and blocks input until another model is selected. + +## Advanced configuration + +The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default for every plugin; [`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) is the provider section this page configures. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors. + +::: tip The form is deliberately small +The Models page exposes only what a route needs to exist: the API key, display name, base URL, API protocol, and for each model its id, display name, context window, and max output tokens. Every other field — reasoning effort levels, image input, request-compatibility switches, headers, timeouts, retry policy — is set in `$DSH_HOME/settings.yaml`, the same document the page writes. Edit it directly, or, when the browser runs on the same machine as the server, open it with **Open configuration file** in the Settings header; the adapters re-read it on the next request, so nothing needs a restart. The subsections below cover the fields most gateways need. +::: ### Image input @@ -64,7 +82,7 @@ llm-pi-ai: - id: second-model ``` -`defaultInput` is a fallback, not an override, and defaults to `[text]`: on a catalog provider it answers only for models the catalog does not describe, so it never removes images from a catalog model that has them. Narrow one of those with that model's own `input`. A catalog provider has no `models` list to put it in, so write it under `modelOverrides`, keyed by model id: +`defaultInput` is a fallback, not an override, and defaults to `[text]`: on a built-in provider it answers only for models its catalog does not describe, so it never removes images from a catalog model that has them. Narrow one of those with that model's own `input`. A built-in provider has no `models` list to put it in, so write it under `modelOverrides`, keyed by model id: ```yaml llm-pi-ai: @@ -79,6 +97,48 @@ Every list must name at least one modality except a model's own, where an empty Both fields state a claim about your endpoint rather than checking it. A model that declares images its endpoint does not serve is not caught here; the provider rejects the request instead. +### Reasoning effort + +The model picker offers an **Effort** menu for a model that declares reasoning levels. A built-in provider's models inherit their levels from the installed catalog. A model you enter by hand declares none, so the Effort entry does not appear in the menu and the endpoint's own default decides whether the model thinks. Declare the levels with `reasoningEfforts` in `$DSH_HOME/settings.yaml`: + +```yaml +llm-pi-ai: + providers: + my-gateway: + apiKeyEnv: GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.example/v1 + reasoning: high + models: + - id: my-reasoner + reasoningEfforts: + off: + high: high + max: max +``` + +Each key is a level the menu offers, and its value is the spelling sent on the wire as `reasoning_effort`, so `max: xhigh` renames a level for a gateway with its own vocabulary. Only `off` may stay empty, because for most endpoints not thinking is the parameter's absence. The route's `reasoning` is the level used while a session has picked none; choosing an effort in the picker saves it, with the model, as the default for new sessions. + +An `off` left empty sends nothing, which only stops a model that thinks on request; an `off` given a value sends that value as `reasoning_effort` instead. A model that thinks unless told not to — DeepSeek V4 behind an OpenAI-compatible gateway, for example — needs `compat.thinkingFormat: deepseek`, which makes `off` send `thinking: {type: disabled}` and every other level send `thinking: {type: enabled}` beside the effort: + +```yaml + models: + - id: deepseek-v4-pro + compat: + thinkingFormat: deepseek + reasoningEfforts: + off: + high: high + max: max +``` + +A built-in provider's model whose gateway does not reason loses its levels with `reasoningEfforts: false` under `modelOverrides`; selecting an effort for it is then refused as `UNSUPPORTED_REASONING_EFFORT`. DeepSeek's own route needs none of this: its models already offer `off`, `low`, `high`, and `max`, and `llm-deepseek.reasoningEffort` sets the default the picker starts from: + +```yaml +llm-deepseek: + reasoningEffort: max +``` + ### Request compatibility A gateway can hold a working key at a reachable address and still refuse every request. pi-ai decides the shape of a request — which role carries the system prompt, which field caps the output, how a thinking level travels — from the endpoint's URL, and an address it does not recognize is addressed as though it were OpenAI itself. Most OpenAI-compatible gateways refuse at least one thing OpenAI accepts. @@ -115,23 +175,16 @@ Each switch belongs to the protocols that declare it, so a switch valid on one ` Every switch, its accepted values, and the protocols that take it are listed under `PiAiCompatProfile` in the [generated `dsh-llm-pi-ai` configuration reference](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) — which is derived from the source, so it cannot fall behind what the adapter accepts. -## Select a model - -Configured providers appear in the model picker. Selecting a model also makes it the default for new sessions. A session that has already sent a request retains the model recorded in its own log. - -If a saved default names a provider that was deleted, the composer displays **Select model** and blocks input until another model is selected. - ## Troubleshooting - **`MISSING_CREDENTIAL`** — Store the provider key through the Models page or supply the referenced environment variable. - **`UNKNOWN_MODEL`** — Select a configured model or add the missing model to the custom provider. - **Fetching available models returns 401** — Check the key. Model discovery calls the OpenAI-compatible `GET /models` endpoint; enter models manually for endpoints that do not provide it. +- **Fetching available models reports neither a `data` array nor a `models` object** — The endpoint's listing is in a format discovery does not read. Enter the models by hand. - **The gateway refuses every request although the key and URL are right** — Its request shape differs from OpenAI's. Start with `compat.supportsDeveloperRole: false` and `compat.maxTokensField: max_tokens` on the route. - **Only reasoning models fail** — pi-ai sends their system prompt as the `developer` role, which the gateway rejects. Set `compat.supportsDeveloperRole: false`. +- **The Effort menu does not appear for a model you entered by hand** — It declares no levels. Add `reasoningEfforts` to the model in `settings.yaml`. +- **`off` does not stop a DeepSeek model from thinking** — An empty `off` sends no reasoning field at all, and an endpoint that thinks by default keeps thinking. Set `compat.thinkingFormat: deepseek` on the model or the route. - **A compat switch is refused as having no value** — A key written with nothing after the colon. Give it a value, or remove the key to keep the installed catalog's. -- **An image is refused before sending** — The model declares no image modality. Give a custom provider's model `input: [text, image]`; DeepSeek's own chat-completions route is text-only and cannot be configured otherwise. +- **An image is refused before sending** — The model declares no image modality. Give a custom provider's model `input: [text, image]`; on DeepSeek's own route, select `deepseek-v4-flash-vision-exp`, the model that declares images. - **The provider rejects a request carrying an image** — The model declares images its endpoint does not actually serve. Remove `image` from whichever list granted it — the model's `input`, or the route's `defaultInput` — then start a new session: the attached image stays in the session log, so the same request repeats until the session moves off it. - -## Advanced configuration - -The generated [plugin configuration catalog](../../config-catalog.md) lists every supported field and default for every plugin; [`dsh-llm-pi-ai`](../../config-catalog.md#deepseek-aidsh-llm-pi-ai) is the provider section this page configures. The [`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.md) and [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.md) references own direct `settings.yaml` configuration, catalog resolution, reasoning controls, credentials, and adapter errors. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index a72be6c706..3af9096f5a 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -12,21 +12,39 @@ 密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。 -## 添加目录提供方 +## 添加内置提供方 -选择**添加提供方**,选取 Anthropic 或 OpenAI 等提供方,输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 +选择**添加提供方**,选取 dsh 自带的提供方;列表显示的是提供方 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`。输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。 -使用原生认证的提供方需要各自的原生凭据。Bedrock、Vertex、Azure 和 Codex 分别使用 AWS 凭据与区域、ADC 项目、`api-version` 和 OAuth;只填写 API 密钥字段无法完成配置。 +通过 OAuth 登录的提供方(例如 Codex)暂不支持。 ## 添加自定义提供方 -对于公司网关、自建服务器或已安装目录中不存在的提供方,选择**添加自定义提供方**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。 +对于公司网关、自建服务器或已安装目录中不存在的提供方,选择**添加自定义提供方**。提供小写 Provider ID、基础 URL、API 协议、凭据和至少一个模型。**API 协议**必须选网关实际使用的那一种,表单提供三种:`openai-completions` 对应 OpenAI Chat Completions,`openai-responses` 对应 OpenAI Responses API,`anthropic-messages` 对应 Anthropic Messages API。一个提供方只使用一种协议,网关同时提供两种时需要建两个提供方。 ![自定义提供方表单:Provider ID、显示名称、API 地址、API 协议、API 密钥](providers-custom-form.zh.png) Provider ID 是永久的,因为请求、已保存会话、模型默认值和凭据引用都会使用它。如需重命名提供方,请添加新提供方并删除旧提供方。显示名称、基础 URL、协议、凭据和模型仍可编辑。 -在**模型目录**中选择**获取可用模型**,可查询表单当前显示的基础 URL 和凭据。选择候选项只会更新草稿;保存前不会存储提供方。目录提供方使用已安装目录,不发起网络请求。 +### 探测模型 + +在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供方则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供方之前不会存储任何内容。 + +探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供方一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供方探测。 + +## 选择模型 + +已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 + +如果已保存默认值指向已删除的提供方,输入框会显示**选择模型**,并在选择其他模型前阻止输入。 + +## 进阶配置 + +自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 + +::: tip 表单刻意保持精简 +模型页只开放让一条路由得以存在的字段:API 密钥、显示名称、API 地址、API 协议,以及每个模型的 ID、显示名称、上下文窗口和最大输出 token 数。其余所有字段——推理等级、图片输入、请求兼容性开关、请求头、超时、重试策略——都在 `$DSH_HOME/settings.yaml` 中设置,也就是模型页写入的同一份文档。可以直接编辑它;浏览器与服务器在同一台机器时,也可以点击设置页顶部的**打开配置文件**打开它。适配器会在下一次请求时重新读取,无需重启任何东西。下面各小节介绍多数网关会用到的字段。 +::: ### 图片输入 @@ -64,7 +82,7 @@ llm-pi-ai: - id: second-model ``` -`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在目录提供方上,它只为目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。目录提供方没有可供填写的 `models` 列表,因此写在 `modelOverrides` 下,以模型 id 为键: +`defaultInput` 是回退值而不是覆盖值,默认为 `[text]`:在内置提供方上,它只为其目录未描述的模型作答,因此绝不会把目录中本就具备图片能力的模型的该能力去掉。要收窄这类模型,请用它自己的 `input`。内置提供方没有可供填写的 `models` 列表,因此写在 `modelOverrides` 下,以模型 id 为键: ```yaml llm-pi-ai: @@ -79,6 +97,48 @@ llm-pi-ai: 这两个字段都是对你端点的断言,而不是对它的检查。声明了端点并不提供的图片能力的模型不会在这里被拦下,改由提供方拒绝该请求。 +### 推理等级 + +对于声明了推理等级的模型,模型选择器会提供**推理等级**菜单。内置提供方的模型从已安装目录继承其等级。手动录入的模型不声明任何等级,因此模型菜单里不会出现推理等级项,由端点自身的默认值决定模型是否思考。请在 `$DSH_HOME/settings.yaml` 中用 `reasoningEfforts` 声明等级: + +```yaml +llm-pi-ai: + providers: + my-gateway: + apiKeyEnv: GATEWAY_API_KEY + api: openai-completions + baseURL: https://gateway.example/v1 + reasoning: high + models: + - id: my-reasoner + reasoningEfforts: + off: + high: high + max: max +``` + +每个键都是菜单提供的一个等级,其值是在协议上以 `reasoning_effort` 发送的写法,因此 `max: xhigh` 可以为自有一套词汇的网关重命名某个等级。只有 `off` 可以留空,因为对多数端点来说,不思考就是不传该参数。路由的 `reasoning` 是会话尚未选择等级时采用的等级;在选择器中选定某个等级后,它会与模型一起保存为新会话的默认值。 + +留空的 `off` 什么都不发送,这只能让「按请求才思考」的模型停下来;给 `off` 一个值,则会把该值作为 `reasoning_effort` 发送。对于「不明确关闭就会思考」的模型——例如 OpenAI 兼容网关后面的 DeepSeek V4——需要 `compat.thinkingFormat: deepseek`:它让 `off` 发送 `thinking: {type: disabled}`,其他每个等级则在 effort 之外再发送 `thinking: {type: enabled}`: + +```yaml + models: + - id: deepseek-v4-pro + compat: + thinkingFormat: deepseek + reasoningEfforts: + off: + high: high + max: max +``` + +网关并不提供推理能力的内置提供方模型,可在 `modelOverrides` 下用 `reasoningEfforts: false` 去掉其等级;之后再为它选择等级会被拒绝并报 `UNSUPPORTED_REASONING_EFFORT`。DeepSeek 自身的路由不需要以上任何配置:其模型已经提供 `off`、`low`、`high` 和 `max`,`llm-deepseek.reasoningEffort` 设置选择器的起始默认值: + +```yaml +llm-deepseek: + reasoningEffort: max +``` + ### 请求兼容性 网关可能持有可用的密钥、地址也通得到,却仍然拒绝每一个请求。pi-ai 依据端点的 URL 决定请求的形状——系统提示词由哪个角色承载、输出上限写在哪个字段、思考级别如何传输——而对于它无法识别的地址,会当作 OpenAI 本身来对待。多数 OpenAI 兼容网关至少会拒绝 OpenAI 所接受的某一样东西。 @@ -115,23 +175,16 @@ llm-pi-ai: 全部开关、各自接受的取值,以及接受它们的协议,都列在[生成的 `dsh-llm-pi-ai` 配置参考](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai)的 `PiAiCompatProfile` 之下——该参考派生自源码,因此不会落后于适配器实际接受的内容。 -## 选择模型 - -已配置的提供方会出现在模型选择器中。选择模型也会将其设为新会话的默认值。已发送过请求的会话会保留自身日志中记录的模型。 - -如果已保存默认值指向已删除的提供方,输入框会显示**选择模型**,并在选择其他模型前阻止输入。 - ## 排错 - **`MISSING_CREDENTIAL`**:通过模型页存储提供方密钥,或提供被引用的环境变量。 - **`UNKNOWN_MODEL`**:选择已配置的模型,或向自定义提供方添加缺失的模型。 - **获取可用模型返回 401**:检查密钥。模型发现会调用 OpenAI 兼容的 `GET /models` 端点;对于不提供该端点的服务,请手动输入模型。 +- **获取可用模型提示既没有 `data` 数组也没有 `models` 对象**:端点返回的列表格式不在探测的读取范围内。请手动输入模型。 - **密钥与地址都正确,网关却拒绝每一个请求**:它的请求形状与 OpenAI 不同。先在路由上设 `compat.supportsDeveloperRole: false` 与 `compat.maxTokensField: max_tokens`。 - **只有推理模型失败**:pi-ai 把它们的系统提示词以 `developer` 角色发出,而网关拒绝该角色。设 `compat.supportsDeveloperRole: false`。 +- **手动录入的模型没有推理等级菜单**:该模型没有声明任何等级。在 `settings.yaml` 中给该模型加上 `reasoningEfforts`。 +- **`off` 无法让 DeepSeek 模型停止思考**:留空的 `off` 不发送任何推理字段,默认思考的端点就继续思考。请在模型或路由上设置 `compat.thinkingFormat: deepseek`。 - **某个 compat 开关因没有值而被拒绝**:冒号后什么都没写。给它一个值,或删掉该键以沿用已安装 catalog 的值。 -- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;DeepSeek 自身的 chat-completions 路由是纯文本的,且无法通过配置改变。 +- **图片在发送前被拒绝**:该模型未声明图片模态。请给自定义提供方的模型加上 `input: [text, image]`;在 DeepSeek 自身的路由上,请选择声明了图片能力的模型 `deepseek-v4-flash-vision-exp`。 - **提供方拒绝了带图片的请求**:该模型声明了其端点实际并不提供的图片能力。请从授予它图片能力的那个列表中移除 `image`——可能是模型的 `input`,也可能是路由的 `defaultInput`——然后开启新会话:附加的图片会留在会话日志里,因此在会话离开它之前,同一个请求会不断重复。 - -## 进阶配置 - -自动生成的[插件配置目录](../../config-catalog.zh.md)列出每个插件的所有受支持字段与默认值;[`dsh-llm-pi-ai`](../../config-catalog.zh.md#deepseek-aidsh-llm-pi-ai) 就是本页所配置的那个提供方段落。[`dsh-llm-pi-ai`](../../../packages/llm/llm-pi-ai/README.zh.md) 和 [`dsh-llm-deepseek`](../../../packages/llm/llm-deepseek/README.zh.md) 参考文档负责直接 `settings.yaml` 配置、目录解析、推理控制、凭据与适配器错误。 diff --git a/package.json b/package.json index cc9ed28a3a..70e8e8e814 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.2-alpha.5", + "version": "0.1.2-rc.1", "license": "MIT", "private": true, "type": "module", @@ -110,6 +110,7 @@ "verify-npm-install-layout": "tsx scripts/verify-npm-install-layout.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/acp/acp/package.json b/packages/acp/acp/package.json index 5e8946c6eb..aff89e6399 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 5f125b33e2..3fa0c1d902 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index a60099926e..ad2b50137d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index ed3e7101f7..e60bfd4517 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/settings-controller/package.json b/packages/api/settings-controller/package.json index f3eb373993..074a5017c7 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json index 4fcec35476..c81537e894 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index f249b1f5b5..7a9fb7d4d0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index bd102976ed..087ac8c2d0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, 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/package.json b/packages/boot/app-boot/package.json index 27540bc343..ee235d5ae3 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, 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..644acd4892 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -149,6 +149,83 @@ 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. 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('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() + } + }) + + 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/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 14e424df1f..37d7b89c3d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json index af9dab2635..4eeb8e6536 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index e4d0227451..24ff47b915 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 10f7fd5816..8f54c08668 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json index b0601999e5..d7fc2a54f7 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json index 7e568dfba7..f6e7d53158 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 63f32d737e..46e1ebe45c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index d70fe8cbba..e40673f6d9 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index dd5fb1ab49..fd84e0880a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 21746815d5..3a94469e67 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 7a6571a167..41f2be2b94 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/store/package.json b/packages/client/store/package.json index c70d9b4f8e..7098fa466e 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 797bf1c6fb..3c2f9ef51c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-approval/package.json b/packages/client/ui-approval/package.json index 7a4a01862d..9e718a46ec 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 5bdf295949..7b9681d70a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-brand-official/package.json b/packages/client/ui-brand-official/package.json index ae4c31086a..020dba8b91 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 1b4a90f5f6..d6071127e6 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-commands/package.json b/packages/client/ui-commands/package.json index 800fc2f8be..10e327f192 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 4c45fd9e12..89b8706b37 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index ad99298a05..ae8f4dfde9 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-browse/package.json b/packages/client/ui-directory-picker-browse/package.json index 36aa15cc6a..1779a0c4c8 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-directory-picker-native/package.json b/packages/client/ui-directory-picker-native/package.json index 0b039464ff..4e3029493a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index ee664305f1..ee50245f4e 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-input-trigger/package.json b/packages/client/ui-input-trigger/package.json index 69cf3fb724..aab8fad6da 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index 08b7c20976..2189543418 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.5", + "version": "0.1.2-rc.1", "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 a67128f5b6..dcc1512f79 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-message-feedback/package.json b/packages/client/ui-message-feedback/package.json index fdf391f56a..561f19f1cc 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index d4b49856fb..2d22a25414 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-permission-presets/package.json b/packages/client/ui-permission-presets/package.json index 61d0c653dd..7e20cdb8db 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 2c9dfc5684..bab20c585d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index a2499cff0e..34acb87346 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-reference/package.json b/packages/client/ui-reference/package.json index 6efa9c8910..6c4446f84f 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-renderer/package.json b/packages/client/ui-renderer/package.json index 294a8dc1c2..84ed2d1f41 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-schedule/package.json b/packages/client/ui-schedule/package.json index 7eb16b43c9..62e5aa506d 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.5", + "version": "0.1.2-rc.1", "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 9694f67383..f4cae67051 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index e3a6c50c9b..593aa4a76a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 4c9a0539d4..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: b3f07e1a788b223f0f49e37b9a4a54662acf6445 -README.zh.md: c23dcf95f6b9e9d47b390c3243bf3d8bb0ef26f1 +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 b3f07e1a78..cac2785886 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 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 c23dcf95f6..7df96a9a1b 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 兼容与 Anthropic Messages 端点**:OpenAI 协议接受标准 `data` 数组或富信息 `models` 对象,Anthropic 则使用原生模型列表路由;其余协议会报告自己无法被询问,其模型需手工填写。 - **未声明的存活路由无处渲染**:未附带可配置提供方声明即注册的路由没有 settings 地址;它在各选择器中仍然可见,但不会出现在本页的行里。 diff --git a/packages/client/ui-settings-models/package.json b/packages/client/ui-settings-models/package.json index 2d558c536b..85d3a9c670 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, 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/client/ui-settings-plugin-inventory/package.json b/packages/client/ui-settings-plugin-inventory/package.json index 6d8f08385e..76dbdf62b4 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings-plugins/package.json b/packages/client/ui-settings-plugins/package.json index 847a79b1c5..ef75045009 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 29d3b1b148..87bd3db954 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index f2f858c502..c268c1be9f 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 1966bb26b5..c131ba0ab1 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index efaf783795..f22ef1855d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 056479aa79..4e284f09cd 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index de0f11452d..6f0fbd4c72 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index b0d6d55497..7a01c24928 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index a7b7b4a886..f5e2189efa 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-user-questions/package.json b/packages/client/ui-user-questions/package.json index 684201142a..07953f0f50 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workflow-run/package.json b/packages/client/ui-workflow-run/package.json index 9df135f57e..39a02d9a77 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index e3bc1516e8..3636ec465d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 2385f95eb4..aefb202344 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.5", + "version": "0.1.2-rc.1", "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 2c0c34ddd5..9d50c46558 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 3ac8030104..e7139eea47 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/command-compact/package.json b/packages/compaction/command-compact/package.json index 0bf21a87f6..18cfaa6399 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-basic/package.json b/packages/compaction/compaction-basic/package.json index 75d7ad032e..9fc5be9ccd 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction-tool-result-pruner/package.json b/packages/compaction/compaction-tool-result-pruner/package.json index b0ff0b4ded..4d11d90947 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/compaction/compaction/package.json b/packages/compaction/compaction/package.json index 3183dc3745..812bccde32 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/agent-instructions/package.json b/packages/context/agent-instructions/package.json index 44e0e1c7fb..53fbce5f55 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference-local/package.json b/packages/context/file-reference-local/package.json index b89cd44d74..6f48106cd0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/file-reference/package.json b/packages/context/file-reference/package.json index 4dd8363b11..278b403457 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 9b8e2ce0d8..9fddcc31f5 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index c50f407a3f..cf16b64849 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index bc756fa9bc..a3e43fc3d4 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 506f14cc5f..7f5ee5468c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 015bb7e1e1..dd3fe864d6 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent-tool-presentation/package.json b/packages/core/agent-tool-presentation/package.json index 90c1675d1f..2267d4bc77 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index d57cfedc13..60ffb53923 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 1dc076fa5e..1854ef3094 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 04101b1bb9..fc4097f748 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 5c697c64e8..7d5b9d05ae 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 8cbbb477b6..58183c984f 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/authorization/package.json b/packages/credentials/authorization/package.json index 9d18e09b94..389df2fc32 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index d907a56873..4ac8f288aa 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index d5fc47d473..322e686d97 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index a50525d332..41aefa8d3d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -27,7 +27,8 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^", @@ -47,6 +48,8 @@ "@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/dsh-terminal-bash": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^" } } diff --git a/packages/e2b/e2b/src/api-url.ts b/packages/e2b/e2b/src/api-url.ts new file mode 100644 index 0000000000..6d113ea581 --- /dev/null +++ b/packages/e2b/e2b/src/api-url.ts @@ -0,0 +1,26 @@ +/** + * The E2B control-plane URL, derived the way the SDK derives it. + * @module @deepseek-ai/dsh-e2b/src/api-url.ts + */ + +/** 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}` +} diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 18c9a7c5db..4333fdc431 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -9,6 +9,8 @@ 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 { proxyRouteFor } from '@deepseek-ai/dsh-http-proxy' +import { e2bApiUrl } from './api-url.ts' export { CommandExitError, @@ -66,6 +68,7 @@ declare module '@deepseek-ai/cordis' { } } + /** * Creates one lazily consumable E2B SDK handle and deletes the sandbox at * timeout or disposal. Creation begins at plugin construction; adapters await @@ -149,11 +152,17 @@ 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. 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 route = proxyRouteFor(new URL(e2bApiUrl())) const sandbox = await Sandbox.create({ apiKey: this.config.apiKey, timeoutMs: this.config.timeoutMs, secure: true, lifecycle: { onTimeout: 'kill' }, + ...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 new file mode 100644 index 0000000000..0bbaca9aa7 --- /dev/null +++ b/packages/e2b/e2b/tests/egress.spec.ts @@ -0,0 +1,75 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installProxyFromEnvironment } 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() }) }) }) + +/** 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 { 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') + }) +}) + +describe('e2b control-plane URL', () => { + it('follows the SDK precedence so the proxy decision matches the real target', async () => { + const { e2bApiUrl } = await import('../src/api-url.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/api-url.ts') + const { proxyRouteFor } = await import('@deepseek-ai/dsh-http-proxy') + const { createLaunchEnvironmentSnapshot } = await import('@deepseek-ai/dsh-launch-environment') + // 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, + ) + 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/e2b/e2b/tsconfig.json b/packages/e2b/e2b/tsconfig.json index 9d1380b4f8..49a7dbb012 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" @@ -17,6 +19,12 @@ }, { "path": "../../util/brand" + }, + { + "path": "../../util/http-proxy" + }, + { + "path": "../../util/launch-environment" } ] } diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index ca7844f726..013dbe0cd5 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index bc3bf1b4f6..9d51e54a62 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/experimental/agent-team-profile/package.json b/packages/experimental/agent-team-profile/package.json index 342814bed4..ca4c1024b9 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.5", + "version": "0.1.2-rc.1", "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 00d32cea02..1ce662a2f1 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.5", + "version": "0.1.2-rc.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/agent-team/package.json b/packages/experimental/agent-team/package.json index 75539a8169..ee4f069335 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.5", + "version": "0.1.2-rc.1", "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 2d40a2410c..1ba7deeac4 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.5", + "version": "0.1.2-rc.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/code-runtime-python/package.json b/packages/experimental/code-runtime-python/package.json index f79af60424..a82ca841b3 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.5", + "version": "0.1.2-rc.1", "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 27d5fc5e50..ce0d712a0f 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.5", + "version": "0.1.2-rc.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/tool-agent-team/package.json b/packages/experimental/tool-agent-team/package.json index 92e59e8d1c..590a05e50f 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.5", + "version": "0.1.2-rc.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-packer/package.json b/packages/experimental/webworker-packer/package.json index 5a0195dc72..5f75d03a5d 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.5", + "version": "0.1.2-rc.1", "private": true, "repository": { "type": "git", diff --git a/packages/experimental/webworker-runtime/package.json b/packages/experimental/webworker-runtime/package.json index f16e02acd2..90e78feb89 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.5", + "version": "0.1.2-rc.1", "private": true, "repository": { "type": "git", diff --git a/packages/extensions/cordis-client-runner/package.json b/packages/extensions/cordis-client-runner/package.json index 1ee8087acd..a5d96d2970 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/cordis-host-runner/package.json b/packages/extensions/cordis-host-runner/package.json index 355ea66d9f..b2f6921bc2 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/tool-cordis/package.json b/packages/extensions/tool-cordis/package.json index 6891f808b0..09c1bc7b3c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/extensions/ui-cordis/package.json b/packages/extensions/ui-cordis/package.json index 0c241fbef6..7b33be2d8d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index ab845a4683..5ca0cf252c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json index a30567d0ce..d5cdde2896 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 6b00f5a4f5..677d66b72c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/README.i18n.yaml b/packages/fs/fs-observation-policy/README.i18n.yaml index da076a74ed..ea395a44aa 100644 --- a/packages/fs/fs-observation-policy/README.i18n.yaml +++ b/packages/fs/fs-observation-policy/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/fs/fs-observation-policy/README.md -README.md: 45534acf97f0a8c90172846cb7bc6221caa7a365 -README.zh.md: 72034458c67dfa6c64981167de7cf4cdace3c033 +README.md: c685e279109964bd1161e7586299d958676331fe +README.zh.md: 79bf0c58e20fa18d9a0ace8a270d86232d3aa6a3 diff --git a/packages/fs/fs-observation-policy/README.md b/packages/fs/fs-observation-policy/README.md index 45534acf97..c685e27910 100644 --- a/packages/fs/fs-observation-policy/README.md +++ b/packages/fs/fs-observation-policy/README.md @@ -43,7 +43,7 @@ With the policy mounted, `write` creates new files but refuses to overwrite an e ### Failures and recovery -An edit without a prior observation fails with code `FS_NOT_OBSERVED` and message `edit requires reading "" first`; editing a target observed absent fails with `FS_NOT_FOUND`. The tools append the recovery instruction — re-read the file, then retry — while preserving the code. Following the remedy on an externally deleted file records absence, so the next guarded write can recreate it without clobbering a concurrent creator. +An edit without a prior observation fails with code `FS_NOT_OBSERVED` and policy reason `edit requires reading "" first`; editing a target observed absent fails with `FS_NOT_FOUND`. The tools normalize unread policy and provider failures to `cannot modify "": file has not been read — read the file, then retry` while preserving the code and original cause. Following the remedy on an externally deleted file records absence, so the next guarded write can recreate it without clobbering a concurrent creator. ----- @@ -106,7 +106,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -This plugin adds no prompt or schema. It rejects an edit without a prior observation with code `FS_NOT_OBSERVED` and exact message `edit requires reading "" first`; editing a target observed absent returns `FS_NOT_FOUND`. Guarded mutations whose positive observation is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper, which appends the recovery instruction to `FS_STALE_VERSION` (`— re-read the file, then retry`) and `FS_NOT_OBSERVED` (`— read the file, then retry`) messages while preserving the code. Following the stale remedy on an externally deleted target records absence: the next guarded write may recreate it with `createIfAbsent`, while the provider atomically preserves any concurrent creator. +This plugin adds no prompt or schema. It rejects an edit without a prior observation with code `FS_NOT_OBSERVED` and policy reason `edit requires reading "" first`; editing a target observed absent returns `FS_NOT_FOUND`. Guarded mutations whose positive observation is stale propagate the provider-owned `FS_STALE_VERSION` error. [`dsh-tool-fs`](../tool-fs/README.md) owns the model-facing error wrapper: it normalizes every `FS_NOT_OBSERVED` source to `cannot modify "": file has not been read — read the file, then retry`, while `FS_STALE_VERSION` retains the provider reason and adds `— re-read the file, then retry`; both preserve the code and original cause. Following the stale remedy on an externally deleted target records absence: the next guarded write may recreate it with `createIfAbsent`, while the provider atomically preserves any concurrent creator. #### Token effect diff --git a/packages/fs/fs-observation-policy/README.zh.md b/packages/fs/fs-observation-policy/README.zh.md index 72034458c6..79bf0c58e2 100644 --- a/packages/fs/fs-observation-policy/README.zh.md +++ b/packages/fs/fs-observation-policy/README.zh.md @@ -43,7 +43,7 @@ kind: "package-reference" ### 失败与恢复 -没有先前观测的编辑以代码 `FS_NOT_OBSERVED` 和消息 `edit requires reading "" first` 失败;编辑被观测为缺失的目标以 `FS_NOT_FOUND` 失败。工具会追加恢复指令——先重新读取文件再重试——同时保留错误码。在外部删除的文件上遵循该恢复指令会记录缺失,因此下一次防护写入可以重新创建它,而不会覆盖并发创建者。 +没有先前观测的编辑以代码 `FS_NOT_OBSERVED` 和策略原因 `edit requires reading "" first` 失败;编辑被观测为缺失的目标以 `FS_NOT_FOUND` 失败。工具把策略和提供方的未读失败统一为 `cannot modify "": file has not been read — read the file, then retry`,同时保留错误码和原始原因。在外部删除的文件上遵循该恢复指令会记录缺失,因此下一次防护写入可以重新创建它,而不会覆盖并发创建者。 ----- @@ -106,7 +106,7 @@ kind: "package-reference" #### 模型看到的内容 -该插件不添加提示词或 schema。没有先前观测时,它会以代码 `FS_NOT_OBSERVED` 和精确消息 `edit requires reading "" first` 拒绝编辑;编辑被观测为缺失的目标返回 `FS_NOT_FOUND`。正向观测陈旧时,带防护的变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.zh.md) 拥有面向模型的错误包装,会为 `FS_STALE_VERSION` 消息追加恢复指令(`— re-read the file, then retry`)、为 `FS_NOT_OBSERVED` 消息追加恢复指令(`— read the file, then retry`),同时保留错误码。外部删除目标后,遵循陈旧恢复指令会记录缺失:下一次带防护的写入可以通过 `createIfAbsent` 重新创建该目标,而提供方会以原子方式保留任何并发创建者写入的文件。 +该插件不添加提示词或 schema。没有先前观测时,它会以代码 `FS_NOT_OBSERVED` 和策略原因 `edit requires reading "" first` 拒绝编辑;编辑被观测为缺失的目标返回 `FS_NOT_FOUND`。正向观测陈旧时,带防护的变更会传播由提供方拥有的 `FS_STALE_VERSION` 错误。[`dsh-tool-fs`](../tool-fs/README.zh.md) 拥有模型侧错误包装:它把所有 `FS_NOT_OBSERVED` 来源规范化为 `cannot modify "": file has not been read — read the file, then retry`,而 `FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`;两者都保留错误码和原始原因。外部删除目标后,遵循陈旧恢复指令会记录缺失:下一次带防护的写入可以通过 `createIfAbsent` 重新创建该目标,而提供方会以原子方式保留任何并发创建者写入的文件。 #### Token 影响 diff --git a/packages/fs/fs-observation-policy/package.json b/packages/fs/fs-observation-policy/package.json index 3379fea8ec..14e067aea7 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs-observation-policy/tests/policy.spec.ts b/packages/fs/fs-observation-policy/tests/policy.spec.ts index 7a72519718..484bd7b87e 100644 --- a/packages/fs/fs-observation-policy/tests/policy.spec.ts +++ b/packages/fs/fs-observation-policy/tests/policy.spec.ts @@ -81,7 +81,10 @@ describe('write-intent decision', () => { describe('edit-intent decision', () => { it('rejects an unread edit with FS_NOT_OBSERVED', async () => { const { ctx } = await setup() - await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ code: 'FS_NOT_OBSERVED' }) + await expect(editIntent(ctx, target('a.txt'), ownerExec({}))).rejects.toMatchObject({ + code: 'FS_NOT_OBSERVED', + message: 'edit requires reading "a.txt" first', + }) }) it('rejects an edit with no owner (cannot prove prior observation)', async () => { diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 45308cf4e2..76dc9df57e 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 138fa2d462..5fcb183245 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 14097343a1..8dbcd78189 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index a90ed42392..f50d9871f4 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: f85c9afc1ed55b8e3b56399d6cfab07c683a501b -README.zh.md: 124c5b30fbec13c09a4d8f4ca6af19b07a45fb0e +README.md: 6da984ff436f3515b4798ddb47e3a15466672f9e +README.zh.md: e4d1ef8483c2e644f2e6308e1965d87773278515 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index f85c9afc1e..6da984ff43 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -71,7 +71,7 @@ With the policy plugin mounted, `write` and `edit` obtain their guard from the ` ### Failures and recovery -Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include `file_path must be a non-empty string`, `limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. Guarded-mutation failures append their remedy: `FS_STALE_VERSION` gets `— re-read the file, then retry`, `FS_NOT_OBSERVED` gets `— read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. +Failures are normalized as `Error: ` with a structured code preserved for callers. Stable messages include `file_path must be a non-empty string`, `limit must be less than or equal to `, `cannot read "": not found`, `cannot read "": not a regular file`, and the image-route refusal `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`. `FS_NOT_OBSERVED` is normalized to `cannot modify "": file has not been read — read the file, then retry`, independent of whether the policy or provider rejected the operation; `FS_STALE_VERSION` retains the provider's reason and appends `— re-read the file, then retry`. After the reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. ----- @@ -98,7 +98,7 @@ The tools are the executor; policy is an event gate. The tools inject no policy | [`src/edit.ts`](src/edit.ts) | `edit` executor: intent waterfall, literal edit, observation | | [`src/read-render.ts`](src/read-render.ts) | Cordis-free windowing and envelope formatting | | [`src/sandbox.ts`](src/sandbox.ts) | Escalation API shared by `write`/`edit`: policy resolution and denial-marker mapping | -| [`src/error.ts`](src/error.ts) | Model-facing remedy appended to `FS_STALE_VERSION` and `FS_NOT_OBSERVED` | +| [`src/error.ts`](src/error.ts) | Stable model-facing diagnostics for guarded-mutation failures | ### Per-tool flow @@ -221,7 +221,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`, `cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`, `cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats` (an extension-less mismatch reports `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`). A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`, `cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`, `cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats` (an extension-less mismatch reports `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`). A failed 16-bit conversion reports `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`. Provider and policy templates are quoted in their package READMEs. The model-facing error wrapper normalizes every `FS_NOT_OBSERVED` source to `cannot modify "": file has not been read — read the file, then retry`; `FS_STALE_VERSION` keeps the provider's reason and adds `— re-read the file, then retry`. Both retain the structured error code and original cause. After that reread confirms absence, `edit` reports `FS_NOT_FOUND` instead of repeating a stale remedy, while `write` uses guarded creation. #### Token effect diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 124c5b30fb..e4d1ef8483 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -71,7 +71,7 @@ kind: "package-reference" ### 失败与恢复 -失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `file_path must be a non-empty string`、`limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。防护变更失败会追加恢复指令:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 +失败被规范化为 `Error: `,并为调用方保留结构化错误码。稳定消息包括 `file_path must be a non-empty string`、`limit must be less than or equal to `、`cannot read "": not found`、`cannot read "": not a regular file`,以及图像路由拒绝 `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`。无论拒绝来自策略还是提供方,`FS_NOT_OBSERVED` 都规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。该次重新读取确认缺失后,`edit` 报告 `FS_NOT_FOUND` 而不会重复陈旧恢复指令,`write` 则使用防护创建。 ----- @@ -98,7 +98,7 @@ kind: "package-reference" | [`src/edit.ts`](src/edit.ts) | `edit` 执行器:意图 waterfall、字面量编辑、观察 | | [`src/read-render.ts`](src/read-render.ts) | 不依赖 Cordis 的窗口构建与信封格式化 | | [`src/sandbox.ts`](src/sandbox.ts) | `write`/`edit` 共享的升权 API:策略解析与拒绝标记映射 | -| [`src/error.ts`](src/error.ts) | 追加到 `FS_STALE_VERSION` 与 `FS_NOT_OBSERVED` 的面向模型恢复指令 | +| [`src/error.ts`](src/error.ts) | 防护变更失败的稳定模型侧诊断 | ### 各工具流程 @@ -221,7 +221,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`、`cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`、`cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`(无扩展名路径的不匹配报告 `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`)。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,`edit` 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;`write` 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": the extension does not declare a supported image format; read_image accepts PNG/JPEG/WebP/GIF files, including extension-less files in those formats`、`cannot read "": the file content is not a supported image format; read_image accepts PNG/JPEG/WebP/GIF`、`cannot read "": the bytes do not decode as a supported PNG/JPEG/WebP/GIF image; the file may be truncated or corrupt`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`(无扩展名路径的不匹配报告 `cannot read "": the file signature claims , but the bytes decode as a different image format; the file may be corrupt`)。16-bit 转换失败会报告 `cannot read "": the 16-bit PNG could not be converted to the normalized 8-bit sRGB form; convert it to an 8-bit PNG/JPEG/WebP and retry`。提供方和策略模板在各自包的 README 中逐字列出。模型侧错误包装把所有 `FS_NOT_OBSERVED` 来源规范化为 `cannot modify "": file has not been read — read the file, then retry`;`FS_STALE_VERSION` 保留提供方原因并追加 `— re-read the file, then retry`。两者都保留结构化错误码和原始原因。该次重新读取确认缺失后,`edit` 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;`write` 则使用带防护的创建。 #### Token 影响 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index e5e42d1022..73e0e495cf 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 0660e40621..82cc4469c6 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -134,7 +134,7 @@ export function applyEditTool(ctx: Context, sandbox: FsSandboxController): void // A sandbox denial becomes the shared [sandbox: …] marker (the model // recognizes it from bash); stale/not-observed failures gain their // model-facing remedy; anything else passes through. - throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) + throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath) } ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) return { diff --git a/packages/fs/tool-fs/src/error.ts b/packages/fs/tool-fs/src/error.ts index e67616887f..ec19b2ca19 100644 --- a/packages/fs/tool-fs/src/error.ts +++ b/packages/fs/tool-fs/src/error.ts @@ -1,34 +1,34 @@ /** - * Model-facing remediation for guarded-mutation failures. The provider's - * `FS_STALE_VERSION` and `FS_NOT_OBSERVED` messages state the condition but - * not the only correct recovery (re-read / read the file), so this package - * appends the remedy at the model boundary; provider messages stay - * machine-oriented and unchanged. + * Model-facing diagnostics for guarded-mutation failures. Providers and + * policies retain operation-specific causes, while this package owns the + * stable message shown to the model. * @module @deepseek-ai/dsh-tool-fs/src/error */ import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsErrorCode } from '@deepseek-ai/dsh-fs' - -/** The remedy appended to each remediable failure code's message. */ -const REMEDIES: Partial> = { - FS_STALE_VERSION: 're-read the file, then retry', - FS_NOT_OBSERVED: 'read the file, then retry', -} /** - * Append the correct recovery instruction to a guarded-mutation failure's - * message. `FS_STALE_VERSION` (the file changed since this session's last - * observation, including a missing target) recovers only by re-reading; - * `FS_NOT_OBSERVED` (no prior read by this session) by reading. The `FsError` - * code is preserved so retry/permission/UI layers keep routing on it, and the - * original error chains as `cause`. Anything else passes through untouched. + * Render the stable model-facing diagnostic for a guarded-mutation failure. + * `FS_STALE_VERSION` keeps the provider's reason and appends its re-read + * remedy. `FS_NOT_OBSERVED` replaces operation-specific policy/provider text + * with one path-aware reason and read remedy. The original error remains the + * cause, and both diagnostics preserve its code for machine routing. Anything + * else passes through untouched. * @param error - the caught value from a write/edit execution. + * @param displayPath - the resolved target path shown to the model. * @returns a remediated `FsError` for the two guarded-mutation codes, else the original value. */ -export function remediateFsError(error: unknown): unknown { +export function remediateFsError(error: unknown, displayPath: string): unknown { if (!(error instanceof FsError)) return error - const remedy = REMEDIES[error.code] - if (!remedy) return error - return new FsError(`${error.message} — ${remedy}`, error.code, { cause: error }) + if (error.code === 'FS_NOT_OBSERVED') { + return new FsError( + `cannot modify "${displayPath}": file has not been read — read the file, then retry`, + error.code, + { cause: error }, + ) + } + if (error.code === 'FS_STALE_VERSION') { + return new FsError(`${error.message} — re-read the file, then retry`, error.code, { cause: error }) + } + return error } diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index ab4820a38b..5b7d066fba 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -115,7 +115,7 @@ export function applyWriteTool(ctx: Context, sandbox: FsSandboxController): void // A sandbox denial becomes the shared [sandbox: …] marker (the model // recognizes it from bash); stale/not-observed failures gain their // model-facing remedy; anything else passes through. - throw remediateFsError(sandbox.mapError(error, sandboxPolicy)) + throw remediateFsError(sandbox.mapError(error, sandboxPolicy), target.displayPath) } ctx.emit('fs/observed', target, { kind: 'present', version: outcome.version }, exec) return { diff --git a/packages/fs/tool-fs/tests/error.spec.ts b/packages/fs/tool-fs/tests/error.spec.ts index 671eb32d9d..e424c3e852 100644 --- a/packages/fs/tool-fs/tests/error.spec.ts +++ b/packages/fs/tool-fs/tests/error.spec.ts @@ -10,26 +10,34 @@ import { remediateFsError } from '../src/error.ts' describe('remediateFsError', () => { it('appends the re-read remedy to FS_STALE_VERSION, preserving the code and chaining the cause', () => { const original = new FsError('cannot edit "x": file changed since it was read', 'FS_STALE_VERSION') - const remedied = remediateFsError(original) as FsError + const remedied = remediateFsError(original, 'x') as FsError expect(remedied).toBeInstanceOf(FsError) expect(remedied.message).toBe('cannot edit "x": file changed since it was read — re-read the file, then retry') expect(remedied.code).toBe('FS_STALE_VERSION') expect(remedied.cause).toBe(original) }) - it('appends the read remedy to FS_NOT_OBSERVED', () => { - const remedied = remediateFsError(new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED')) as FsError - expect(remedied.message).toBe('edit requires reading "x" first — read the file, then retry') - expect(remedied.code).toBe('FS_NOT_OBSERVED') + it('normalizes policy and provider FS_NOT_OBSERVED failures to one diagnostic', () => { + const sources = [ + new FsError('edit requires reading "x" first', 'FS_NOT_OBSERVED'), + new FsError('cannot overwrite existing "x" without reading it first', 'FS_NOT_OBSERVED'), + ] + const remedied = sources.map(error => remediateFsError(error, 'x') as FsError) + expect(remedied.map(error => error.message)).toEqual([ + 'cannot modify "x": file has not been read — read the file, then retry', + 'cannot modify "x": file has not been read — read the file, then retry', + ]) + expect(remedied.map(error => error.code)).toEqual(['FS_NOT_OBSERVED', 'FS_NOT_OBSERVED']) + expect(remedied.map(error => error.cause)).toEqual(sources) }) it('leaves other FsError codes untouched', () => { const original = new FsError('no match anywhere', 'FS_EDIT_NOT_FOUND') - expect(remediateFsError(original)).toBe(original) + expect(remediateFsError(original, 'x')).toBe(original) }) it('leaves non-FsError values untouched', () => { const original = new Error('boom') - expect(remediateFsError(original)).toBe(original) + expect(remediateFsError(original, 'x')).toBe(original) }) }) diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index 28315545c1..1907e652aa 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -40,6 +40,10 @@ function text(result: { content: { type: string; text?: string }[] }): string { return result.content.filter(b => b.type === 'text').map(b => b.text).join('') } +function notObservedDiagnostic(path: string): string { + return `Error: cannot modify "${path}": file has not been read — read the file, then retry` +} + afterEach(async () => { await fiber.dispose() await rm(dir, { recursive: true, force: true }) @@ -71,9 +75,7 @@ describe('default deployment (with dsh-fs-observation-policy)', () => { const result = await call('write', { file_path: 'a.txt', content: 'clobber' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) - // The model-facing text names the remedy, not just the condition. - expect(text(result)).toContain('without reading it first') - expect(text(result)).toContain('read the file, then retry') + expect(text(result)).toBe(notObservedDiagnostic(join(dir, 'a.txt'))) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('original') }) @@ -151,9 +153,7 @@ describe('default deployment (with dsh-fs-observation-policy)', () => { const result = await call('edit', { file_path: 'a.txt', old_string: 'world', new_string: 'there' }) expect(result.isError).toBe(true) expect(result.error).toMatchObject({ info: { code: 'FS_NOT_OBSERVED' } }) - // The policy's refusal reaches the model with the read remedy appended. - expect(text(result)).toContain('edit requires reading') - expect(text(result)).toContain('read the file, then retry') + expect(text(result)).toBe(notObservedDiagnostic(join(dir, 'a.txt'))) expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello world') }) diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index a849114c88..d989328728 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index d386516512..fa35205ae5 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal-round-driver/package.json b/packages/goal/goal-round-driver/package.json index fc24c5ede4..9fd840242c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 7ec02573e0..b993422f28 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 8f6b99d23a..73e3703fe6 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/guard/repeat-tool-reminder/package.json b/packages/guard/repeat-tool-reminder/package.json index 9dd322c991..9ea544213b 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index b0889d7783..31901ecb93 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index c1c04f3be7..6e19b86b0e 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-claude-code/package.json b/packages/hooks/hooks-claude-code/package.json index 3d3bae25f3..b5f306a92a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 96f2f52fde..6883eb43e5 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 01c5fcf132..e1bb6176ef 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index ba37329049..cc1063a632 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index d88ea47576..33d6b5d039 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index 02a7d650d6..9e7ba5bd7d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 05886f83f9..432cdc9563 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/plugin-inventory/package.json b/packages/host/plugin-inventory/package.json index eafa27d9da..9df3be70b0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index aef01f1bab..eeb08fe3f4 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index 8035a9457d..1ac9451de7 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 790212b124..faf2d6134a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index 23fdd4ba05..ce05b679dc 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index ed671365c1..a8ac0aaee9 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index f733cf7570..b26b69fae6 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 4d02e66f6e..45074af2fb 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index 09a1520696..e5f7729392 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index c074160ab5..34b32d40e7 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/package.json b/packages/jobs/tool-jobs/package.json index b89b55e60f..65de60e4f0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/deepseek-llm-api-extensions/package.json b/packages/llm/deepseek-llm-api-extensions/package.json index b3e96291e0..3e17971587 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index d12c430993..4c7cdebf15 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -62,6 +62,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^" + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "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..46a3ca5d77 --- /dev/null +++ b/packages/llm/llm-deepseek/tests/egress.spec.ts @@ -0,0 +1,70 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installProxyFromEnvironment } 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() }) }) }) + +/** 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 { 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 d0a247a8af..876332451e 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -52,6 +52,9 @@ }, { "path": "../../identity/anonymous-user-id" + }, + { + "path": "../../util/http-proxy" } ] } diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 803c42af07..656439bf24 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: c0aacbe3e0b727cd7b6b163814dc96859b954b9c +README.zh.md: 7b8cef7db2ba5cebe3e42345b308716ae0099c03 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 5994a72f28..c0aacbe3e0 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?limit=1000` semantics with `x-api-key` and `anthropic-version`; its listing URL accepts the API root with or without a trailing `/v1` because gateway documentation publishes both spellings, and only that listing URL normalizes the segment, so model requests receive the configured `baseURL` unchanged. 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 5f1c893128..7b8cef7db2 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?limit=1000` 语义;其列表 URL 接受带或不带末尾 `/v1` 的 API 根地址,因为网关文档两种写法都会发布,且只有该列表 URL 会归一化这一段,模型请求收到的仍是配置原样的 `baseURL`。已配置且具名的路由会在 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/package.json b/packages/llm/llm-pi-ai/package.json index e2917f17a4..4aa7caf701 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -53,6 +53,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-timeout": "workspace:^" + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" } } diff --git a/packages/llm/llm-pi-ai/src/discovery.ts b/packages/llm/llm-pi-ai/src/discovery.ts index e8e353e25e..09be7b6207 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,26 @@ 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' + +/** Largest model-list page accepted by Anthropic's public endpoint; discovery reads one page and does not follow `has_more`. */ +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 @@ -49,16 +57,34 @@ 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 +} + +/** Per-route capacities OpenRouter nests under each entry. */ +interface ListingTopProvider { + max_completion_tokens?: 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 + top_provider?: ListingTopProvider | null } /** A positive integer field of a listing entry, or `undefined` when absent or unusable. */ @@ -78,13 +104,21 @@ function label(...candidates: readonly unknown[]): string | undefined { } /** - * Join the endpoint base with the listing path. The base 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 instead of losing - * them to `URL` resolution. + * Join the endpoint base with the protocol's listing path. The base 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 instead + * of losing them to `URL` resolution. OpenAI protocols list at + * `{baseURL}/models`. Anthropic lists at `{root}/v1/models`, where the root is + * the base without trailing slashes and without one trailing `/v1` segment: + * gateway documentation publishes both spellings of the same root. Only this + * listing URL normalizes that segment; model requests receive the configured + * `baseURL` unchanged. */ -function listingUrl(baseURL: string): string { - return `${baseURL.replace(/\/+$/, '')}/models` +function listingUrl(baseURL: string, api: string): string { + const base = baseURL.replace(/\/+$/, '') + if (api !== 'anthropic-messages') return `${base}/models` + const root = base.endsWith('/v1') ? base.slice(0, -3) : base + return `${root}/v1/models?limit=${String(ANTHROPIC_MODEL_LIMIT)}` } /** @@ -131,29 +165,63 @@ 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 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. 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 + * 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, + entry?.top_provider?.max_completion_tokens, + ) models.push({ id, - ...name === undefined ? {} : { name }, + name, ...contextWindow === undefined ? {} : { contextWindow }, ...maxTokens === undefined ? {} : { maxTokens }, }) @@ -235,7 +303,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 +316,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..e71439af33 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -1,3 +1,4 @@ +import { readFile } from 'node:fs/promises' import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -111,6 +112,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 +125,106 @@ 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, + 'primitive-route': 'not a model record', + }, + }), + }) + 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?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'])) + .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 +317,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 +341,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 +371,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` @@ -386,3 +483,61 @@ describe('probe key format', () => { expect(headers.has('authorization')).toBe(false) }) }) + +/** + * Replies recorded from live endpoints on 2026-09-02, plus the reply + * Anthropic's List Models reference documents. Each file keeps the reply's + * top-level fields and entry objects verbatim; only a recorded entry list is + * cut down to the named entries so the archive stays small. + */ +const RECORDED_LISTINGS = [ + { + name: 'OpenRouter GET /api/v1/models', + file: 'openrouter-2026-09-02.json', + api: 'openai-completions', + models: [ + { id: 'anthropic/claude-fable-5.1', name: 'Anthropic: Claude Fable 5.1', contextWindow: 1_000_000, maxTokens: 128_000 }, + // The router's own aggregate route reports no completion cap. + { id: 'openrouter/auto-beta', name: 'Auto Router (Beta)', contextWindow: 2_000_000 }, + { id: 'deepseek/deepseek-v4-flash', name: 'DeepSeek: DeepSeek V4 Flash 0423', contextWindow: 1_048_576, maxTokens: 384_000 }, + ], + }, + { + name: 'the models.dev anthropic provider object', + file: 'models-dev-anthropic-2026-09-02.json', + api: 'openai-completions', + models: [ + { id: 'claude-opus-4-7', name: 'Claude Opus 4.7', contextWindow: 1_000_000, maxTokens: 128_000 }, + { id: 'claude-fable-5-1', name: 'Claude Fable 5.1', contextWindow: 1_000_000, maxTokens: 128_000 }, + { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5 (latest)', contextWindow: 200_000, maxTokens: 64_000 }, + ], + }, + { + name: 'DeepSeek GET /models', + file: 'deepseek-2026-09-02.json', + api: 'openai-completions', + models: [ + { id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' }, + { id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' }, + { id: 'deepseek-v4-flash-vision-exp', name: 'deepseek-v4-flash-vision-exp' }, + ], + }, + { + name: "Anthropic's documented GET /v1/models example", + file: 'anthropic-reference-example.json', + api: 'anthropic-messages', + // The reference example fills both capacities with 0, which is not a + // usable capacity, so the row carries the name alone. + models: [{ id: 'claude-opus-5', name: 'Claude Opus 5' }], + }, +] + +describe('recorded provider listings', () => { + it.each(RECORDED_LISTINGS)('reads $name as recorded', async ({ file, api, models }) => { + const body = await readFile(new URL(`./fixtures/model-listings/${file}`, import.meta.url), 'utf8') + const server = await listingServer({ body }) + const ctx = await harness() + + await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: server.url, api })).resolves.toEqual(models) + }) +}) 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..ba9c38c651 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/egress.spec.ts @@ -0,0 +1,68 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installProxyFromEnvironment } 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() }) }) }) + +/** 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 { 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/packages/llm/llm-pi-ai/tests/fixtures/model-listings/anthropic-reference-example.json b/packages/llm/llm-pi-ai/tests/fixtures/model-listings/anthropic-reference-example.json new file mode 100644 index 0000000000..0321b46d09 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/fixtures/model-listings/anthropic-reference-example.json @@ -0,0 +1,76 @@ +{ + "data": [ + { + "id": "claude-opus-5", + "capabilities": { + "batch": { + "supported": true + }, + "citations": { + "supported": true + }, + "code_execution": { + "supported": true + }, + "context_management": { + "clear_thinking_20251015": { + "supported": true + }, + "clear_tool_uses_20250919": { + "supported": true + }, + "compact_20260112": { + "supported": true + }, + "supported": true + }, + "effort": { + "high": { + "supported": true + }, + "low": { + "supported": true + }, + "max": { + "supported": true + }, + "medium": { + "supported": true + }, + "supported": true, + "xhigh": { + "supported": true + } + }, + "image_input": { + "supported": true + }, + "pdf_input": { + "supported": true + }, + "structured_outputs": { + "supported": true + }, + "thinking": { + "supported": true, + "types": { + "adaptive": { + "supported": true + }, + "enabled": { + "supported": true + } + } + } + }, + "created_at": "2026-07-24T00:00:00Z", + "display_name": "Claude Opus 5", + "max_input_tokens": 0, + "max_tokens": 0, + "type": "model" + } + ], + "first_id": "first_id", + "has_more": true, + "last_id": "last_id" +} diff --git a/packages/llm/llm-pi-ai/tests/fixtures/model-listings/deepseek-2026-09-02.json b/packages/llm/llm-pi-ai/tests/fixtures/model-listings/deepseek-2026-09-02.json new file mode 100644 index 0000000000..85b8bb4529 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/fixtures/model-listings/deepseek-2026-09-02.json @@ -0,0 +1,20 @@ +{ + "object": "list", + "data": [ + { + "id": "deepseek-v4-flash", + "object": "model", + "owned_by": "deepseek" + }, + { + "id": "deepseek-v4-pro", + "object": "model", + "owned_by": "deepseek" + }, + { + "id": "deepseek-v4-flash-vision-exp", + "object": "model", + "owned_by": "deepseek" + } + ] +} diff --git a/packages/llm/llm-pi-ai/tests/fixtures/model-listings/models-dev-anthropic-2026-09-02.json b/packages/llm/llm-pi-ai/tests/fixtures/model-listings/models-dev-anthropic-2026-09-02.json new file mode 100644 index 0000000000..e8d827f353 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/fixtures/model-listings/models-dev-anthropic-2026-09-02.json @@ -0,0 +1,146 @@ +{ + "id": "anthropic", + "env": [ + "ANTHROPIC_API_KEY" + ], + "npm": "@ai-sdk/anthropic", + "name": "Anthropic", + "doc": "https://docs.anthropic.com/en/docs/about-claude/models", + "models": { + "claude-opus-4-7": { + "id": "claude-opus-4-7", + "name": "Claude Opus 4.7", + "description": "Stronger Opus tier for advanced software work and high-stakes reasoning", + "family": "claude-opus", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "tool_call": true, + "structured_output": true, + "temperature": false, + "knowledge": "2026-01-31", + "release_date": "2026-04-14", + "last_updated": "2026-04-16", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 1000000, + "output": 128000 + }, + "cost": { + "input": 5, + "output": 25, + "cache_read": 0.5, + "cache_write": 6.25 + } + }, + "claude-fable-5-1": { + "id": "claude-fable-5-1", + "name": "Claude Fable 5.1", + "description": "Claude model for demanding reasoning and long-horizon agentic work", + "family": "claude-fable", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "effort", + "values": [ + "low", + "medium", + "high", + "xhigh", + "max" + ] + } + ], + "tool_call": true, + "structured_output": true, + "temperature": false, + "knowledge": "2026-06", + "release_date": "2026-09-01", + "last_updated": "2026-09-01", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 1000000, + "output": 128000 + }, + "cost": { + "input": 10, + "output": 50, + "cache_read": 0.25, + "cache_write": 12.5 + } + }, + "claude-haiku-4-5": { + "id": "claude-haiku-4-5", + "name": "Claude Haiku 4.5 (latest)", + "description": "Fast Claude lane for lightweight agents, office tasks, and responsive chat", + "family": "claude-haiku", + "attachment": true, + "reasoning": true, + "reasoning_options": [ + { + "type": "budget_tokens", + "min": 1024 + } + ], + "tool_call": true, + "structured_output": true, + "temperature": true, + "knowledge": "2025-02-28", + "release_date": "2025-10-15", + "last_updated": "2025-10-15", + "modalities": { + "input": [ + "text", + "image", + "pdf" + ], + "output": [ + "text" + ] + }, + "open_weights": false, + "limit": { + "context": 200000, + "output": 64000 + }, + "cost": { + "input": 1, + "output": 5, + "cache_read": 0.1, + "cache_write": 1.25 + } + } + } +} diff --git a/packages/llm/llm-pi-ai/tests/fixtures/model-listings/openrouter-2026-09-02.json b/packages/llm/llm-pi-ai/tests/fixtures/model-listings/openrouter-2026-09-02.json new file mode 100644 index 0000000000..58c03a65ea --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/fixtures/model-listings/openrouter-2026-09-02.json @@ -0,0 +1,299 @@ +{ + "data": [ + { + "id": "anthropic/claude-fable-5.1", + "canonical_slug": "anthropic/claude-fable-5.1-20260831", + "hugging_face_id": null, + "name": "Anthropic: Claude Fable 5.1", + "created": 1788285838, + "description": "Claude Fable 5.1 improves on Claude Fable 5 across the board, with the biggest gains in agentic coding, long-running agentic workflows, and knowledge work: long code refactors, front-end and visual...", + "context_length": 1000000, + "architecture": { + "modality": "text+image+file->text", + "input_modalities": [ + "text", + "image", + "file" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "Claude", + "instruct_type": null + }, + "pricing": { + "prompt": "0.00001", + "completion": "0.00005", + "web_search": "0.01", + "input_cache_read": "0.00000025", + "input_cache_write": "0.0000125", + "input_cache_write_1h": "0.00002" + }, + "top_provider": { + "context_length": 1000000, + "max_completion_tokens": 128000, + "is_moderated": true + }, + "per_request_limits": null, + "supported_parameters": [ + "include_reasoning", + "max_completion_tokens", + "max_tokens", + "reasoning", + "reasoning_effort", + "response_format", + "stop", + "structured_outputs", + "tools", + "verbosity" + ], + "default_parameters": {}, + "supported_voices": null, + "knowledge_cutoff": null, + "expiration_date": null, + "links": { + "details": "/api/v1/models/anthropic/claude-fable-5.1-20260831/endpoints" + }, + "benchmarks": { + "design_arena": [ + { + "arena": "models", + "category": "website", + "elo": 1351, + "win_rate": 58.5, + "rank": 2 + } + ], + "artificial_analysis": { + "intelligence_index": 65.7, + "coding_index": 81.6, + "agentic_index": 61.3 + } + }, + "reasoning": { + "mandatory": true, + "supported_efforts": [ + "max", + "xhigh", + "high", + "medium", + "low" + ], + "default_effort": "high" + } + }, + { + "id": "openrouter/auto-beta", + "canonical_slug": "openrouter/auto-beta", + "hugging_face_id": null, + "name": "Auto Router (Beta)", + "created": 1784311165, + "description": "Auto Router (Beta) is a task-aware router from OpenRouter. It classifies each request, then routes it the [most popular model](/rankings#task-spend) for that task based on aggregate spend, filtered by your...", + "context_length": 2000000, + "architecture": { + "modality": "text+image+file+audio+video->text+image", + "input_modalities": [ + "text", + "image", + "audio", + "file", + "video" + ], + "output_modalities": [ + "text", + "image" + ], + "tokenizer": "Router", + "instruct_type": null + }, + "pricing": { + "prompt": "-1", + "completion": "-1" + }, + "top_provider": { + "context_length": null, + "max_completion_tokens": null, + "is_moderated": false + }, + "per_request_limits": null, + "supported_parameters": [ + "frequency_penalty", + "include_reasoning", + "logit_bias", + "logprobs", + "max_tokens", + "min_p", + "prediction", + "presence_penalty", + "reasoning", + "reasoning_effort", + "repetition_penalty", + "response_format", + "seed", + "stop", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_a", + "top_k", + "top_logprobs", + "top_p", + "web_search_options" + ], + "default_parameters": { + "temperature": null, + "top_p": null, + "top_k": null, + "frequency_penalty": null, + "presence_penalty": null, + "repetition_penalty": null + }, + "supported_voices": null, + "knowledge_cutoff": null, + "expiration_date": null, + "links": { + "details": "/api/v1/models/openrouter/auto-beta/endpoints" + } + }, + { + "id": "deepseek/deepseek-v4-flash", + "canonical_slug": "deepseek/deepseek-v4-flash-20260423", + "hugging_face_id": "deepseek-ai/DeepSeek-V4-Flash", + "name": "DeepSeek: DeepSeek V4 Flash 0423", + "created": 1777000666, + "description": "DeepSeek V4 Flash is an efficiency-optimized Mixture-of-Experts model from DeepSeek with 284B total parameters and 13B activated parameters, supporting a 1M-token context window. It is designed for fast inference and...", + "context_length": 1048576, + "architecture": { + "modality": "text->text", + "input_modalities": [ + "text" + ], + "output_modalities": [ + "text" + ], + "tokenizer": "DeepSeek", + "instruct_type": null + }, + "pricing": { + "prompt": "0.00000008708", + "completion": "0.00000017416", + "input_cache_read": "0.000000017416" + }, + "top_provider": { + "context_length": 1024000, + "max_completion_tokens": 384000, + "is_moderated": false + }, + "per_request_limits": null, + "supported_parameters": [ + "frequency_penalty", + "include_reasoning", + "logit_bias", + "logprobs", + "max_completion_tokens", + "max_tokens", + "min_p", + "presence_penalty", + "reasoning", + "reasoning_effort", + "repetition_penalty", + "response_format", + "seed", + "stop", + "structured_outputs", + "temperature", + "tool_choice", + "tools", + "top_a", + "top_k", + "top_logprobs", + "top_p" + ], + "default_parameters": {}, + "supported_voices": null, + "knowledge_cutoff": null, + "expiration_date": null, + "links": { + "details": "/api/v1/models/deepseek/deepseek-v4-flash-20260423/endpoints" + }, + "benchmarks": { + "design_arena": [ + { + "arena": "models", + "category": "3d", + "elo": 1229, + "win_rate": 49.3, + "rank": 40 + }, + { + "arena": "models", + "category": "asciiart", + "elo": 1138, + "win_rate": 42.8, + "rank": 47 + }, + { + "arena": "models", + "category": "codecategories", + "elo": 1226, + "win_rate": 48.9, + "rank": 43 + }, + { + "arena": "models", + "category": "dataviz", + "elo": 1148, + "win_rate": 40.6, + "rank": 74 + }, + { + "arena": "models", + "category": "gamedev", + "elo": 1232, + "win_rate": 50.2, + "rank": 39 + }, + { + "arena": "models", + "category": "svg", + "elo": 1193, + "win_rate": 48.4, + "rank": 34 + }, + { + "arena": "models", + "category": "uicomponent", + "elo": 1188, + "win_rate": 44.7, + "rank": 58 + }, + { + "arena": "models", + "category": "website", + "elo": 1224, + "win_rate": 49.1, + "rank": 45 + } + ], + "artificial_analysis": { + "intelligence_index": 42.1, + "coding_index": 56.2, + "agentic_index": 33.7 + } + }, + "reasoning": { + "mandatory": false, + "supported_efforts": [ + "xhigh", + "high" + ], + "default_effort": "high" + } + } + ], + "total_count": 421, + "links": { + "next": null + } +} 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') diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index f2480308be..c7d43104c0 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": "../../util/http-proxy" } ] } diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 08ff973ee6..b2ab3f7e9c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index f812c8bd75..213d6f4416 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/plugin-package-inventory-deepseek/package.json b/packages/llm/plugin-package-inventory-deepseek/package.json index 9d708e0a32..7133707cfd 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index b59599b402..7d6d322acf 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp-stdio/package.json b/packages/lsp/lsp-stdio/package.json index ce9eefb609..d8c5652950 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 3f11633c2d..ab800ecb8d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index d5967c657d..9f561f63b9 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 181c9f5dbb..f9bdcfaa81 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -50,6 +50,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" } } 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..02f8e6b7e2 --- /dev/null +++ b/packages/mcp/mcp-client/tests/egress.spec.ts @@ -0,0 +1,41 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { installProxyFromEnvironment } 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() }) }) }) + +/** 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 { 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 8c466a1656..ff00795585 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" @@ -29,6 +31,9 @@ }, { "path": "../../util/timeout" + }, + { + "path": "../../util/http-proxy" } ] } diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 6137699f07..f8edff4006 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 6f6f9805c2..a8541e77af 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 71ad037cb1..30d0a5fdd1 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/runtime-diagnostics/invariants/package.json b/packages/runtime-diagnostics/invariants/package.json index df096f1840..538a804934 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index fc79a97130..d64f21a711 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 1a3b7d3f3c..c497d1b402 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index e8e7101765..53abd4f58f 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 6d0e99fa9a..5c3164917a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/schedule/schedule/package.json b/packages/schedule/schedule/package.json index 19282525c2..eff81516f3 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/client/package.json b/packages/sdk/client/package.json index e8e140390f..d90dfc5736 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/protocol/package.json b/packages/sdk/protocol/package.json index 9aa58ad3d3..4ddf04d2ea 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/sdk/server/package.json b/packages/sdk/server/package.json index f01c1c2c9d..42dcdeb477 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-log-export/package.json b/packages/session-query/session-log-export/package.json index 9b51f5adfc..99ebfd79ff 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index b1c6b88234..ec09b12404 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 54fdafea1f..56ddf87330 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 2b064c1f92..9887129ec7 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index fbd84ee9b6..5477c761ed 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-log-deepseek/package.json b/packages/session/session-log-deepseek/package.json index 898d840483..2e28955c73 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 6825b00c2e..2b0cd6f9cb 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 580406aa93..8b7221239c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 8b5ebdb655..c359e5bd60 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 7abf8292a6..190993a070 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-stats/package.json b/packages/session/session-stats/package.json index a2290391aa..9ca44c4106 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 53cbe5d05a..1d240150bc 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -57,6 +57,7 @@ "@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/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" } } 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..229b5f3fb4 --- /dev/null +++ b/packages/session/session-telemetry-otel/tests/egress.spec.ts @@ -0,0 +1,82 @@ +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 +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() }) }) }) + +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 }) +}) + +/** 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): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(OpenTelemetrySessionBackend, { + mode: SessionTelemetryMode.FULL, + exporter: { url: `http://${host}/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 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/package.json b/packages/session/session-telemetry/package.json index a2acebfe6b..689cb2af98 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.5", + "version": "0.1.2-rc.1", "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 bfda2819f5..307ec9de3a 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.5", + "version": "0.1.2-rc.1", "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 759d25c917..a26e57f934 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index cc5260d597..bb5c8a04f8 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 4796c2405e..52f4b0d805 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/session/session-turn-outline/package.json b/packages/session/session-turn-outline/package.json index 455fdace74..a1df19f1a0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings-file/package.json b/packages/settings/settings-file/package.json index 26a747974c..9a7d0a1d6e 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index a82cbca836..00da4957cf 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-local/package.json b/packages/shell/bash-local/package.json index 2463cbc122..b76178d4ba 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/bash-sandbox/package.json b/packages/shell/bash-sandbox/package.json index fc82e1c504..15c77884c6 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-local/package.json b/packages/shell/pwsh-local/package.json index cc57761bd3..a1ae767fd1 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/pwsh-sandbox/package.json b/packages/shell/pwsh-sandbox/package.json index 07611b9efc..6887e7849f 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell-env/package.json b/packages/shell/shell-env/package.json index 76b373b4af..517d59e094 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/shell/package.json b/packages/shell/shell/package.json index 6571e6c3e5..074febadda 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash-persistent/package.json b/packages/shell/tool-bash-persistent/package.json index 803139af30..231db575a0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-bash/package.json b/packages/shell/tool-bash/package.json index 2db6c305bf..176642f236 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/package.json b/packages/shell/tool-pwsh-persistent/package.json index f28de881bd..d3146ad65c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index 58b05082d0..729d9f8073 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -95,11 +95,19 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp ' idleSilenceMs: 300', ' handoffGraceMs: 300', ' scrollbackLines: 20000', - ' timeoutMs: 60000', + // The first call pays the full pwsh cold-start latency (spawn + .NET + + // PSReadLine + Defender) inside the tool deadline; a 60s bound on the + // fully loaded self-hosted Windows pool is exceeded often enough to + // reset the session mid-test (2026-09-01, two runs ~62s each). 300s + // matches the dsh-tool-pwsh-persistent product default; the + // dsh-terminal-bash value bounds one send plus the complete startup + // sequence, so it covers the same cold start (its 30s product default + // would not). + ' timeoutMs: 300000', ' disposeGraceMs: 500', "- name: '@deepseek-ai/dsh-tool-pwsh-persistent'", ' config:', - ' timeoutMs: 60000', + ' timeoutMs: 300000', '', ].join('\n')) diff --git a/packages/shell/tool-pwsh/package.json b/packages/shell/tool-pwsh/package.json index fc3f61498a..f39a6d9829 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 5400420046..1a92c9b5c0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill-filesystem/package.json b/packages/skill/skill-filesystem/package.json index 26589bcc4e..fc5dda6a3f 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 396bade970..7197b02b68 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 161cee3dc8..814c8b7c0d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 3cb3f4dd8d..e3a89f694d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index c0c0c22650..9fb80b20f2 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index e71913a546..32b137f5c0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 0546ce688b..497e64d225 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index fb0a1c9c59..fbe5e4e4f4 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index c50a81eea7..a924f04c6a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index be1fb943d8..7f59ed4d0d 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index c7393cf1a4..a55e09eac7 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 15ecefc5df..b8817b8deb 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 322fea2317..5d48201933 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 74d87e76f5..ba636c5336 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-fork-in-process/package.json b/packages/subagent/subagent-fork-in-process/package.json index 82dc185678..7ac6491976 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-in-process-driver/package.json b/packages/subagent/subagent-in-process-driver/package.json index a96b44c9ad..e461c80052 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent-spawn-in-process/package.json b/packages/subagent/subagent-spawn-in-process/package.json index e09a8123f2..b9dee3ab86 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 05be0b467a..25ecd63314 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 25cd9bbb34..c33282e314 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 05367cbad1..36222dd9a9 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 38fbefab6b..9f840e0086 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index f44d031797..5c1cdf3b68 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -27,9 +27,12 @@ ], "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^" } } diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 1ebe471142..0cda343664 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 { 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' @@ -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,6 +66,14 @@ 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 } + // 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. 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(proxyEnvironmentForChild())) { + if (value === undefined) Reflect.deleteProperty(env, name) + else env[name] = value + } return env } diff --git a/packages/subprocess/subprocess/tests/egress.spec.ts b/packages/subprocess/subprocess/tests/egress.spec.ts new file mode 100644 index 0000000000..9c1fd2692b --- /dev/null +++ b/packages/subprocess/subprocess/tests/egress.spec.ts @@ -0,0 +1,163 @@ +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 { clearedProxyEnv, installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { scrubbedParentEnv } from '../src/index.ts' + +/** + * 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 = [] + 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(() => { + 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 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 dispose = await installProxyFromEnvironment( + createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]), + () => undefined, + ) + let childEnv: Record = {} + 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(seen.join('|')).toContain('child-probe.invalid') + else expect(seen).toEqual([]) + }) + + it('a child Node reaches a proxy the user gave only as ALL_PROXY', async () => { + process.env.ALL_PROXY = proxyUrl + const dispose = await installProxyFromEnvironment( + createLaunchEnvironmentSnapshot([{ source: 'process', values: { ALL_PROXY: proxyUrl } }]), + () => undefined, + ) + 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 dispose = await installProxyFromEnvironment( + createLaunchEnvironmentSnapshot([{ + source: 'process', + values: { HTTP_PROXY: proxyUrl, https_proxy: 'socks5://127.0.0.1:1080' }, + }]), + () => undefined, + ) + 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; 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() + } + }) + + it('gives a child the same routing as its parent for a scheme the user never named', async () => { + process.env.HTTP_PROXY = proxyUrl + const dispose = await installProxyFromEnvironment( + createLaunchEnvironmentSnapshot([{ source: 'process', values: { HTTP_PROXY: proxyUrl } }]), + () => undefined, + ) + 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 + // 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).toBe(proxyUrl) + } 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 754725418e..e72e6a3dd7 100644 --- a/packages/subprocess/subprocess/tsconfig.json +++ b/packages/subprocess/subprocess/tsconfig.json @@ -13,6 +13,12 @@ }, { "path": "../../../vendor/cordis" + }, + { + "path": "../../util/http-proxy" + }, + { + "path": "../../util/launch-environment" } ] } diff --git a/packages/subprocess/win32-process/package.json b/packages/subprocess/win32-process/package.json index 02970e2833..1b04d3b37b 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": "Shared low-level Win32 process, stdio, and Job Object primitives", - "version": "0.1.2-alpha.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal-bash/package.json b/packages/terminal/terminal-bash/package.json index 978ed1318a..bf325bb2bf 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/terminal/package.json b/packages/terminal/terminal/package.json index 965166a540..19258166f8 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/terminal/tool-terminal/package.json b/packages/terminal/tool-terminal/package.json index 417de38632..1208f85038 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/agent-loop-testkit/package.json b/packages/test-support/agent-loop-testkit/package.json index cd464acfc7..5cf1dca3e4 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/client-runtime/package.json b/packages/test-support/client-runtime/package.json index 2a67c094e2..22eeddb1c9 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-mock-server/package.json b/packages/test-support/llm-mock-server/package.json index 66279569f3..135db9ad61 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/llm-replay/package.json b/packages/test-support/llm-replay/package.json index 56153cf056..1e014b4de0 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/test-support/loader-smoke/package.json b/packages/test-support/loader-smoke/package.json index 7bde44e8d1..0086b3fe99 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -34,13 +34,15 @@ "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" } } diff --git a/packages/test-support/loader-smoke/src/index.ts b/packages/test-support/loader-smoke/src/index.ts index 86317f20bc..100bdca67a 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 { clearedProxyEnv } from '@deepseek-ai/dsh-http-proxy' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -109,7 +110,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 989ad3f0b0..801da47770 100644 --- a/packages/test-support/loader-smoke/tsconfig.json +++ b/packages/test-support/loader-smoke/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../util/http-proxy" } ] } diff --git a/packages/test-support/session-snapshot/package.json b/packages/test-support/session-snapshot/package.json index 1c543e7d7e..2a84abc0e7 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -35,7 +35,8 @@ }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^" + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", @@ -49,6 +50,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", - "@types/js-yaml": "^4.0.9" + "@types/js-yaml": "^4.0.9", + "@deepseek-ai/dsh-http-proxy": "workspace:^" } } diff --git a/packages/test-support/session-snapshot/src/harness.ts b/packages/test-support/session-snapshot/src/harness.ts index 628d334337..f1874a77d1 100644 --- a/packages/test-support/session-snapshot/src/harness.ts +++ b/packages/test-support/session-snapshot/src/harness.ts @@ -35,6 +35,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from './launcher.ts' +import { clearedProxyEnv } from '@deepseek-ai/dsh-http-proxy' import { captureWorkspaceSnapshot, type WorkspaceSnapshotEntry } from './workspace.ts' export type { AgentUnderTest } from './launcher.ts' @@ -257,6 +258,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 8be01d94b1..81b7b4e8ad 100644 --- a/packages/test-support/session-snapshot/tsconfig.json +++ b/packages/test-support/session-snapshot/tsconfig.json @@ -16,6 +16,9 @@ }, { "path": "../../core/session" + }, + { + "path": "../../util/http-proxy" } ] } diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 12fa142763..b32e87e51f 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 99faa2d453..7f5732eee3 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 7789f7c5c4..366ae921f1 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/protocol/package.json b/packages/typert/protocol/package.json index 303fece98c..1e1cfb198b 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 354938683c..7ed8e19cb1 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, 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/util/atomic-write/package.json b/packages/util/atomic-write/package.json index b37b9f42ec..9910a906da 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index adfedbba8c..85432947cb 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/crypto/package.json b/packages/util/crypto/package.json index 69f8b4077c..38e0cf0a63 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/deque/package.json b/packages/util/deque/package.json index db596ca69b..6888402d6a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/home-paths/package.json b/packages/util/home-paths/package.json index 13064c8ec4..cd1ded1380 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/http-proxy/README.i18n.yaml b/packages/util/http-proxy/README.i18n.yaml new file mode 100644 index 0000000000..18e7ec1621 --- /dev/null +++ b/packages/util/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/util/http-proxy/README.md +README.md: 023d8a2bad23647072fd249b862f4fe3ca865139 +README.zh.md: 6fcdf6b97dc6eb4f413b704d522b665c83ea70ca diff --git a/packages/util/http-proxy/README.md b/packages/util/http-proxy/README.md new file mode 100644 index 0000000000..023d8a2bad --- /dev/null +++ b/packages/util/http-proxy/README.md @@ -0,0 +1,126 @@ +--- +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, 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 + +- [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, 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 + +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 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 | + +`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, 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 — 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 + +`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. + +### Failures + +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. + +----- + + +## Understand the implementation + +### Design philosophy + +**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. 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 + +| File | Holds | +|---|---| +| `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 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. + +----- + + +## 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. 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, 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`. + + +### 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. + +
+ +**Runtime invariant:** No companion is published. The one piece of mutable state here — the active policy — is asserted against the dispatcher it installs by unit tests that dispose the registration and observe a real loopback proxy. diff --git a/packages/util/http-proxy/README.zh.md b/packages/util/http-proxy/README.zh.md new file mode 100644 index 0000000000..6fcdf6b97d --- /dev/null +++ b/packages/util/http-proxy/README.zh.md @@ -0,0 +1,126 @@ +--- +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 自身够不到的场合由四个函数覆盖——安装策略、询问某个请求怎么发、把策略交给派生的子进程、以及为重放清掉它。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +无需挂载,也无需配置。`dsh` 启动器会在第一个插件加载之前,为每个 profile 解析并安装策略,因此导出了 `HTTPS_PROXY` 的用户在所有位置都会走代理。本包是库而非插件,因为传输策略每个进程只有一个答案:没有第二个实现可替换,也没有比进程更窄的作用域可赋予。 + +### 编写新的出站调用 + +普通 `fetch()` 已经走代理,任何最终落到 `globalThis.fetch` 的 SDK 也一样——MCP HTTP 传输与 pi-ai 提供方栈都是如此。不要对任何 SDK 想当然,去查。 + +| 你要写的东西 | 使用 | +|---|---| +| 普通请求,或最终落到 `globalThis.fetch` 的 SDK | 什么都不用——全局 dispatcher 已经在路由它 | +| 需要按“这次请求是否走代理”分支的调用 | `proxyRouteFor(url)` | +| 接受自有代理 URL 的 SDK | `proxyRouteFor(url)`,把 `route.proxy` 传进去 | +| 由你自己构造环境的派生进程 | 把 `proxyEnvironmentForChild()` 应用到它上面(`undefined` 表示删除) | +| 必须连到自带 fixture 服务器的测试框架 | 把 `clearedProxyEnv()` 应用到该派生进程 | + +`proxyRouteFor` 给出的不只是答案,还有该答案所假定的传输:走代理的那一支携带着此刻正按该策略路由的 dispatcher。若调用方先读策略、再自建传输,卸载就可能落在两次读取之间,把请求发往其分支从未放行的去处。 + +自建传输的 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 这两个漏洞正是这样被发现的,而某次升级若开始静默地把遥测送去代理,也由它拦下。 + +### 策略读取哪些值 + +`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` 自行识别整个网段,因为列表条目无法表达一个范围。 + +### 失败处理 + +本包无法使用的代理值——SOCKS 或 PAC URL、无法解析的字符串、不受支持的协议——会被报告并跳过,该 scheme 转为直连。该变量可能是用户为其他工具导出的,不应因此阻止 agent 启动。 + +----- + + +## 理解实现 + +### 设计理念 + +**一次解析,一个匹配器。** `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:` 代理。有一处例外是为了保护子进程自身:当子进程收到的某个值是本包拒绝过的——比如为 `curl` 保留的 SOCKS URL——就不再设置 `NODE_USE_ENV_PROXY`,因为 Node 在该标志下会在运行程序之前先解析 `HTTP_PROXY` 与 `HTTPS_PROXY`,遇到这类值直接退出。此时子 Node 直连(本进程已为该协议如此报告),而不是根本起不来。 + +### 源码地图 + +| 文件 | 承载 | +|---|---| +| `src/policy.ts` | 解析与绕过匹配;诊断只点名变量,从不带出它的值。不引入任何传输实现,因此在没有 undici 的环境中仍可加载。 | +| `src/install.ts` | 全局 dispatcher、生效策略记录、路由与子进程环境。动态引入 undici。 | +| `src/index.ts` | 本包的对外面:四个函数与一个类型。 | + +### 绕过匹配 + +一个条目写的是主机名,它连同其下所有子域名一起匹配:`NO_PROXY=example.com` 也会放行 `api.example.com`。前缀 `.` 或 `*.` 可以写,含义相同。条目可带 `: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 的 `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` 的原因。 + + +### 开发备注 + +
+面向维护者的工作上下文——点击展开 + +userland undici 能触及 Node 内置的 `fetch`,依赖的是两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合,不是约定——参见 [corepack#834](https://github.com/nodejs/corepack/issues/834) 中它失效的实例。`tests/install.spec.ts` 断言真实请求会抵达一个 loopback 代理,因此破坏该耦合的版本升级会在那里失败,而不是流到线上。 + +
+ +**运行时不变量:** 不发布伴生入口。本包唯一的可变状态——生效中的策略——由单元测试对照它所安装的 dispatcher 断言:测试会释放注册并观察一个真实的 loopback 代理。 diff --git a/packages/util/http-proxy/package.json b/packages/util/http-proxy/package.json new file mode 100644 index 0000000000..d75ba94f36 --- /dev/null +++ b/packages/util/http-proxy/package.json @@ -0,0 +1,39 @@ +{ + "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-rc.1", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/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" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "undici": "^8.10.0" + }, + "devDependencies": { + "@deepseek-ai/cordis": "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..f12de6f093 --- /dev/null +++ b/packages/util/http-proxy/src/index.ts @@ -0,0 +1,23 @@ +/** + * 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. 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, and telemetry are covered without touching their code. + * + * 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. + * + * 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 { + 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 new file mode 100644 index 0000000000..1bd05bbcd3 --- /dev/null +++ b/packages/util/http-proxy/src/install.ts @@ -0,0 +1,315 @@ +/** + * 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 { Dispatcher, Pool } from 'undici' +import { + isSupportedProxyUrl, + 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. */ +let active: ProxyPolicy | undefined + +/** + * The proxy environment as the user exported it, or `undefined` when no policy is installed. + * + * 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 + * 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 + +/** + * How this process must send one request. + * + * 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 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 } +} + +/** + * 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. + */ +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 = 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 Object.entries(previous)) { + if (value === undefined) Reflect.deleteProperty(process.env, name) + else process.env[name] = value + } + } +} + +/** + * 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 a direct dispatcher and leaves the environment untouched. + * + * 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. + */ +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 previousInstalled = installed + // The install underneath published its normalized policy into `process.env`, which is what a + // spawned child copies. With no policy active there is no normalization to stand behind, so the + // user's own values return for the window and the outer install's come back when it ends. An + // install underneath that proxied nothing published nothing, and there is nothing to put back. + const restoreEnv = inheritedProxyEnv === undefined ? undefined : writeProxyEnv(inheritedProxyEnv) + 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 + restoreEnv?.() + 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() + } +} + + + + +/** + * The proxy environment a spawned child needs. + * + * 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` does + * not read `ALL_PROXY`, so a child of a parent that resolved its proxy from that name 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 + * their separators and IPv4-range support. Non-Node children (curl, git, pnpm) ignore the flag and + * read the variables themselves. + * + * The flag is withheld when a proxy value the child receives is one this package refused. Node + * parses `HTTP_PROXY` and `HTTPS_PROXY` under that flag before running the program, and exits on a + * scheme other than `http:` or `https:` — so a SOCKS value kept for `curl` would stop every Node + * child from starting. Without the flag such a child connects directly, as this process already + * reported for that scheme, and `curl` still reads the value it was kept for. + * + * 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 proxyEnvironmentForChild(): Readonly> { + 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 + } + 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 +} + +/** + * 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/src/policy.ts b/packages/util/http-proxy/src/policy.ts new file mode 100644 index 0000000000..cf89f09709 --- /dev/null +++ b/packages/util/http-proxy/src/policy.ts @@ -0,0 +1,343 @@ +/** + * Proxy policy resolution: the pure, transport-free half of this package. It turns the launch + * environment 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 + */ + +/** + * 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 + * 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]'] + +/** + * 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:']) + +/** 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' | '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' + /** 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 +} + + +/** 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: EnvLookup, + 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 +} + +/** + * 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. + */ +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's usability, distinguishing a rejected slot from an empty one. + */ +function acceptProxyUrl( + candidate: { value: string; name: string } | undefined, + diagnostics: ProxyDiagnostic[], +): ProxyCandidate { + if (candidate === undefined) return ABSENT + 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 { 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; connecting directly for that scheme — set an http:// or https:// proxy URL instead`, + }) + 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; connecting directly for that scheme — set an http:// or https:// proxy URL instead`, + }) + return { kind: 'rejected' } + } + 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. + * + * @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) +} + +/** + * 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 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 } +} + +/** 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. 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. + * @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. + * + * 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, whose own layering already prefers real variables over `.env` files. + * @returns the policy to install plus every rejected candidate. + */ +export function resolveProxyPolicy(env: EnvLookup): ProxyResolution { + const diagnostics: ProxyDiagnostic[] = [] + const all = acceptProxyUrl(readEnv(env, 'all_proxy'), diagnostics) + const allValue = all.kind === 'accepted' ? all.value : undefined + const envHttp = acceptProxyUrl(readEnv(env, 'http_proxy'), diagnostics) + const envHttps = acceptProxyUrl(readEnv(env, 'https_proxy'), diagnostics) + 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, httpProxy) + if (httpProxy === undefined && httpsProxy === undefined) return { policy: DIRECT_POLICY, diagnostics } + return { + policy: { + ...httpProxy === undefined ? {} : { httpProxy }, + ...httpsProxy === undefined ? {} : { httpsProxy }, + noProxy: withLoopback(readEnv(env, 'no_proxy')?.value), + source: 'env', + }, + 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 + if (isLoopbackHost(url.hostname)) return undefined + return bypassesProxy(policy.noProxy, url) ? undefined : proxy +} diff --git a/packages/util/http-proxy/tests/install.spec.ts b/packages/util/http-proxy/tests/install.spec.ts new file mode 100644 index 0000000000..a717bec599 --- /dev/null +++ b/packages/util/http-proxy/tests/install.spec.ts @@ -0,0 +1,458 @@ +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' +import { getGlobalDispatcher } from 'undici' +import { + 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[] = [] +let proxy: Server +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) }) + }) +} + +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') + }) + 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)}` + originUrl = `http://127.0.0.1:${String(originAddress.port)}/probe` +}) + +afterAll(async () => { + await Promise.all([close(proxy), close(origin)]) +}) + +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 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) } +} + +/** 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 install(proxyAll()) + try { + await expect((await fetch(proxyTarget)).text()).resolves.toBe('VIA-PROXY') + expect(proxied).toEqual([`GET ${proxyTarget}`]) + } finally { + await dispose() + } + }) + + it('connects directly when the bypass list covers the target', async () => { + const { dispose } = await install(env({ HTTP_PROXY: proxyUrl, NO_PROXY: 'origin.test' })) + try { + 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 install(proxyAll('example.com')) + try { + expect(process.env.http_proxy).toBe(proxyUrl) + expect(process.env.HTTP_PROXY).toBe(proxyUrl) + expect(process.env.no_proxy).toContain('example.com') + expect(process.env.NO_PROXY).toContain('example.com') + } finally { + await dispose() + } + }) + + it('removes an environment name the policy leaves unset', async () => { + process.env.HTTPS_PROXY = 'http://stale.example' + // 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 { + await dispose() + expect(process.env.HTTPS_PROXY).toBe('http://stale.example') + delete process.env.HTTPS_PROXY + } + }) + + it('restores the dispatcher, the route, and the environment on disposal', async () => { + const before = getGlobalDispatcher() + const beforeEnv = process.env.HTTP_PROXY + const { dispose } = await install(proxyAll()) + expect(getGlobalDispatcher()).not.toBe(before) + expect(proxyRouteFor(new URL(proxyTarget)).proxied).toBe(true) + await dispose() + expect(getGlobalDispatcher()).toBe(before) + 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 when the user exported none', async () => { + const before = getGlobalDispatcher() + process.env.HTTP_PROXY = 'http://untouched.example' + const { dispose, reported } = await install(env({})) + try { + expect(getGlobalDispatcher()).toBe(before) + expect(process.env.HTTP_PROXY).toBe('http://untouched.example') + expect(reported).toEqual([]) + expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false }) + } finally { + await dispose() + delete process.env.HTTP_PROXY + } + }) + + 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 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 + // 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(proxyTarget)).text()).resolves.toBe('VIA-PROXY') + expect(proxied).toEqual([`GET ${proxyTarget}`]) + } finally { + await dispose() + } + }) +}) + +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') + // 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') + 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 { + expect(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false }) + } finally { + 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('proxyEnvironmentForChild', () => { + it('is empty when no policy is installed', () => { + expect(proxyEnvironmentForChild()).toEqual({}) + }) + + 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(proxyEnvironmentForChild()).toEqual({}) + } finally { + await dispose() + } + }) + + it('hands a child the values the user exported, not this process\'s normalization', async () => { + 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]') + // 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() + } + }) + }) + + it('fills a scheme the user named in neither casing, so a child Node is not left direct', async () => { + 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) + 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. + process.env.HTTP_PROXY = proxyUrl + // 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 })) + 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 outer.dispose() + } + }) + }) +}) + +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 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(proxyRouteFor(new URL(proxyTarget))).toEqual({ proxied: false }) + } finally { + 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.dispose() + } + }) +}) + +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' + process.env.HTTP_PROXY = 'http://before.example' + 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 + // 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('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 new file mode 100644 index 0000000000..3493eb54b6 --- /dev/null +++ b/packages/util/http-proxy/tests/matcher-parity.spec.ts @@ -0,0 +1,75 @@ +import { createServer, type Server } from 'node:http' +import type { AddressInfo } from 'node:net' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +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` + * 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, 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 }, + { 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() }) }) +}) + +/** 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 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, url) !== undefined, agent: agentProxied }) + .toEqual({ ours: !bypassed, agent: !bypassed }) + } finally { + await dispose() + } + }) +}) diff --git a/packages/util/http-proxy/tests/policy.spec.ts b/packages/util/http-proxy/tests/policy.spec.ts new file mode 100644 index 0000000000..daed8e5790 --- /dev/null +++ b/packages/util/http-proxy/tests/policy.spec.ts @@ -0,0 +1,242 @@ +import { describe, expect, it } from 'vitest' +import type { EnvLookup } from '../src/policy.ts' +import { + bypassesProxy, + isLoopbackHost, + proxyForUrl, + resolveProxyPolicy, + DIRECT_POLICY, +} from '../src/policy.ts' + +/** Windows folds environment names, so a case distinction cannot be expressed there at all. */ +const FOLDS_ENV_CASE = process.platform === 'win32' + +const PROXY = 'http://127.0.0.1:7897' +const OTHER = 'http://127.0.0.1:8080' + +/** + * The environment resolution reads. Windows folds names case-insensitively, and the launcher's own + * snapshot does the same, so this stand-in folds too — otherwise a case-distinction case would + * assert POSIX behaviour on a platform that cannot have it. + */ +function env(values: Record): EnvLookup { + const folded = FOLDS_ENV_CASE + ? Object.fromEntries(Object.entries(values).map(([name, value]) => [name.toUpperCase(), value])) + : values + return { get: (name) => { + const value = folded[FOLDS_ENV_CASE ? name.toUpperCase() : name] + return value === undefined ? undefined : { value } + } } +} + + +describe('loopback routing', () => { + 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({})) + 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 })) + // 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', () => { + const { policy } = resolveProxyPolicy(env({ http_proxy: ' ', HTTP_PROXY: PROXY })) + 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) + 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('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) + 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') + // 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', () => { + const { diagnostics } = resolveProxyPolicy(env({ HTTP_PROXY: 'ftp://proxy.example' })) + expect(diagnostics[0]?.message).toMatch(/unsupported ftp:\/\/ scheme/) + }) + +}) + +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() + }) +}) diff --git a/packages/util/http-proxy/tsconfig.json b/packages/util/http-proxy/tsconfig.json new file mode 100644 index 0000000000..0b174e83f6 --- /dev/null +++ b/packages/util/http-proxy/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ] +} diff --git a/packages/util/launch-environment/package.json b/packages/util/launch-environment/package.json index e572c1f029..22e5678645 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 307d842840..7292d6e8f8 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/output-retention/package.json b/packages/util/output-retention/package.json index 89bf06b3e9..269f2a179a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/time/package.json b/packages/util/time/package.json index 413e86bd6b..0926292844 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index da889b60fb..6d76c3709a 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/values/package.json b/packages/util/values/package.json index e819fa7ff7..9d76bdfd12 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/util/workspace-path/package.json b/packages/util/workspace-path/package.json index a2122f9078..4ee7bc888f 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index bf7c8775d6..babc8e8614 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/web/web-fetch-http/package.json b/packages/web/web-fetch-http/package.json index 5a1d992226..57978a3ff5 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -29,7 +29,8 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-web": "workspace:^" + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:^", @@ -39,6 +40,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/dsh-web": "workspace:^" + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "workspace:^" } } diff --git a/packages/web/web-fetch-http/src/network.ts b/packages/web/web-fetch-http/src/network.ts index 102ffe27a4..f14bf276b4 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 { Dispatcher, Response } from 'undici' + import ipaddr from 'ipaddr.js' import { WebError } from '@deepseek-ai/dsh-web' @@ -158,14 +159,34 @@ function embeddedIpv4Address(bytes: readonly number[], prefixLength: Nat64Prefix } /** - * 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. + * Whether a hostname is an IP literal that {@link resolvePublicAddresses} would refuse. * - * @param url - validated HTTP(S) URL. + * 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 agent whose lookup callback returns only the already validated address set. The + * URL hostname remains intact for HTTP Host and TLS SNI. + * + * 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. - * @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, @@ -173,15 +194,15 @@ 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. + // 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') - const dispatcher = new Agent({ - autoSelectFamily: true, - connect: { lookup: createPinnedLookup(addresses) }, - }) + // 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 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) { @@ -190,10 +211,38 @@ export async function requestPinned( } } +/** + * 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, + requestVia, } type LookupCallback = ( diff --git a/packages/web/web-fetch-http/src/provider.ts b/packages/web/web-fetch-http/src/provider.ts index 8f783d4ed7..a6ddbe03a6 100644 --- a/packages/web/web-fetch-http/src/provider.ts +++ b/packages/web/web-fetch-http/src/provider.ts @@ -10,7 +10,8 @@ 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 { publicHttpNetwork } from './network.ts' +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' @@ -114,12 +115,28 @@ 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. + // + // 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 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, { - '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..bb75046b35 --- /dev/null +++ b/packages/web/web-fetch-http/tests/proxy.spec.ts @@ -0,0 +1,159 @@ +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 { 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' + +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 + +/** + * 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 { + 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() }) }), + ]) +}) + +/** + * 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 installProxy() + + const result = await new HttpFetchProvider(limits).fetch({ url: proxyTarget }) + + expect(result.body.content).toBe('via-proxy') + 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() + }) + + 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 }]) + // 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 }) + + 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.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') + 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 + // 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('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) => { + proxied.push(request.url ?? '') + response.writeHead(302, { location: 'http://elsewhere.example/next' }) + response.end() + }) + 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 installProxy() + + 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 c6fb75a5c1..6aa7bf1eee 100644 --- a/packages/web/web-fetch-http/tsconfig.json +++ b/packages/web/web-fetch-http/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../web" + }, + { + "path": "../../util/http-proxy" } ] } diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 31bfc344e2..f68b3b770f 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -46,6 +46,7 @@ "@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-http-proxy": "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..d37bb838c6 --- /dev/null +++ b/packages/web/web-search-deepseek/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 { installProxyFromEnvironment } 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() }) }) }) + +/** 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 { 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 f9e5787c60..0d41c51e4d 100644 --- a/packages/web/web-search-deepseek/tsconfig.json +++ b/packages/web/web-search-deepseek/tsconfig.json @@ -34,6 +34,9 @@ }, { "path": "../../settings/settings" + }, + { + "path": "../../util/http-proxy" } ] } diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 3b35725157..afcdfa2c09 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -37,6 +37,7 @@ "devDependencies": { "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "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..b2475abb94 --- /dev/null +++ b/packages/web/web-search-exa/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 { installProxyFromEnvironment } 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() }) }) }) + +/** 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 { 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 35a9cde789..bcab1fb0c8 100644 --- a/packages/web/web-search-exa/tsconfig.json +++ b/packages/web/web-search-exa/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../web" + }, + { + "path": "../../util/http-proxy" } ] } diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 131bd84498..6301eb992c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -37,6 +37,7 @@ "devDependencies": { "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-http-proxy": "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..542cb1db48 --- /dev/null +++ b/packages/web/web-search-perplexity/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 { installProxyFromEnvironment } 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() }) }) }) + +/** 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 { 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 35a9cde789..bcab1fb0c8 100644 --- a/packages/web/web-search-perplexity/tsconfig.json +++ b/packages/web/web-search-perplexity/tsconfig.json @@ -22,6 +22,9 @@ }, { "path": "../web" + }, + { + "path": "../../util/http-proxy" } ] } diff --git a/packages/web/web/package.json b/packages/web/web/package.json index e6b506e2f1..a4f1c82afb 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook-github/package.json b/packages/webhook/webhook-github/package.json index 8df8673a1c..ed0efc2536 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/webhook/webhook/package.json b/packages/webhook/webhook/package.json index e65b012e52..69721ea308 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 79d5867a84..ed0c2b5ad9 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 95e5bb47ac..79e47b601e 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/workflow/workflow-worker-thread/package.json b/packages/workflow/workflow-worker-thread/package.json index 6ec0dcabc5..42c823c95c 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, @@ -59,6 +59,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "tsx": "^4.19.2" + "tsx": "^4.19.2", + "@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 45090dec46..2cd5ac4a7c 100644 --- a/packages/workflow/workflow-worker-thread/src/host.ts +++ b/packages/workflow/workflow-worker-thread/src/host.ts @@ -29,7 +29,11 @@ interface ChildRecord { } /** - * The scrubbed worker environment: no ambient credentials, no loader flags. + * 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` 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..6e99470fdf --- /dev/null +++ b/packages/workflow/workflow-worker-thread/tests/egress.spec.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest' +import { clearedProxyEnv, installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy' +import { workerSpawnEnv } from '../src/host.ts' + +/** 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 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 Object.keys(clearedProxyEnv())) 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/packages/workflow/workflow-worker-thread/tsconfig.json b/packages/workflow/workflow-worker-thread/tsconfig.json index 730a3e61d9..45193e0318 100644 --- a/packages/workflow/workflow-worker-thread/tsconfig.json +++ b/packages/workflow/workflow-worker-thread/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../workflow" + }, + { + "path": "../../util/http-proxy" } ] } diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index f3eda09122..6586121f72 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index bc49019b02..2812834028 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.5", + "version": "0.1.2-rc.1", "publishConfig": { "access": "public" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a726e4225f..5467f65ebe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -202,6 +202,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/util/http-proxy '@deepseek-ai/dsh-jobs-local': specifier: workspace:^ version: link:../../packages/jobs/jobs-local @@ -4495,6 +4498,12 @@ importers: '@deepseek-ai/dsh-fs-e2b': specifier: workspace:^ version: link:../fs-e2b + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy + '@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 @@ -6133,6 +6142,9 @@ importers: '@deepseek-ai/dsh-home-paths': specifier: workspace:^ version: link:../../util/home-paths + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -6185,6 +6197,9 @@ importers: '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -6452,6 +6467,9 @@ importers: '@deepseek-ai/dsh-attachment-local': specifier: workspace:^ version: link:../../attachment/attachment-local + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -7253,6 +7271,9 @@ importers: '@deepseek-ai/dsh-command-feedback': specifier: workspace:^ version: link:../../feedback/command-feedback + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -8701,6 +8722,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy + '@deepseek-ai/dsh-launch-environment': + specifier: workspace:^ + version: link:../../util/launch-environment packages/subprocess/subprocess-local: dependencies: @@ -8993,6 +9020,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9030,6 +9060,9 @@ importers: '@deepseek-ai/dsh-compaction': specifier: workspace:^ version: link:../../compaction/compaction + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm @@ -9194,6 +9227,19 @@ importers: specifier: workspace:^ version: link:../../../vendor/cordis + 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': @@ -9319,6 +9365,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout @@ -9344,6 +9393,9 @@ importers: '@deepseek-ai/dsh-credentials-local': specifier: workspace:^ version: link:../../credentials/credentials-local + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -9366,6 +9418,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -9382,6 +9437,9 @@ importers: '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-launch-environment': specifier: workspace:^ version: link:../../util/launch-environment @@ -9606,6 +9664,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../test-support/agent-loop-testkit + '@deepseek-ai/dsh-http-proxy': + specifier: workspace:^ + version: link:../../util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -9790,6 +9851,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/util/http-proxy '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../packages/runtime-diagnostics/invariants diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index db08b00204..f1ada26c49 100644 --- a/python/development.i18n.yaml +++ b/python/development.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 python/development.md -development.md: 72acaec2b3ec2142fc176d940f1a2854e44ebad8 -development.zh.md: 8bd17ad7e604b1cdc4456053bcec95c9e8e503cb +development.md: aa0144d7eaa66711d0f08316d4445da060918ca8 +development.zh.md: a35f6fc8de1bdbd282fd8999a1440fde0c400b34 diff --git a/python/development.md b/python/development.md index 72acaec2b3..aa0144d7ea 100644 --- a/python/development.md +++ b/python/development.md @@ -13,7 +13,7 @@ pnpm install pnpm exec tsx scripts/build-exe-for-python-sdk.ts ``` -Use `--skip-build` when the required `lib/` artifacts already exist, or `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64` to select platforms. Build each target on its native architecture. Products land in `dist-exe/` and the script syncs the selected carriers into `python/sdk-runtime/`. Windows emits `.exe` and `-rg.exe`; macOS also syncs the matching spawn helper required by `node-pty`. +Use `--skip-build` when the required `lib/` artifacts already exist, or `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64` to select platforms. Build each target on its native architecture. Products land in `dist-exe/` and the script syncs the selected carriers into `python/sdk-runtime/`. Windows emits `.exe` and `-rg.exe`; macOS also syncs the matching spawn helper required by `node-pty`. ## Validate the SDK @@ -79,11 +79,11 @@ pip install \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` -The runtime distribution is wheel-only. The release pipeline publishes four platform wheels with the pure SDK wheel: Linux x64, Linux arm64, macOS 14 or newer on arm64, and Windows x64 (`win_amd64`). A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. +The runtime distribution is wheel-only. The release pipeline publishes five platform wheels with the pure SDK wheel: Linux x64, Linux arm64, macOS 14 or newer on arm64 and x64, and Windows x64 (`win_amd64`). A `python-v` tag is accepted only when it matches the repository version; prerelease repository versions such as `0.0.1-rc.1` use their normalized PEP 440 spelling, such as `0.0.1rc1`, inside wheel filenames and metadata. ## Validate a release candidate -Manually run the GitHub `Release (Python)` workflow with `publish=false` to build all five wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. The run has no registry credentials; a dry run cannot enter either publication job. +Manually run the GitHub `Release (Python)` workflow with `publish=false` to build all six wheels, install the Linux release set on Python 3.10 and 3.14, check exact filenames and metadata, enforce PyPI's default per-file size limit, and retain one aggregate artifact with SHA-256 hashes. The run has no registry credentials; a dry run cannot enter either publication job. Public publication runs from the private automation repository. Package metadata points to the separate read-only public source mirror, which does not run release Actions. The private repository defines the repository variable `PYPI_PUBLISHER_REPOSITORY` as its own `owner/name` and keeps `PUBLIC_PYPI_RELEASE_ENABLED=false` except during an intentional release. diff --git a/python/development.zh.md b/python/development.zh.md index 8bd17ad7e6..a35f6fc8de 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -13,7 +13,7 @@ pnpm install pnpm exec tsx scripts/build-exe-for-python-sdk.ts ``` -所需 `lib/` 产物已存在时使用 `--skip-build`;如需选择平台,请使用 `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64`。每个目标都应在其原生架构上构建。产物写入 `dist-exe/`,脚本会将所选载体同步到 `python/sdk-runtime/`。Windows 会生成 `.exe` 与 `-rg.exe`;macOS 构建还会同步 `node-pty` 所需的配套 spawn 辅助程序。 +所需 `lib/` 产物已存在时使用 `--skip-build`;如需选择平台,请使用 `--targets=node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64`。每个目标都应在其原生架构上构建。产物写入 `dist-exe/`,脚本会将所选载体同步到 `python/sdk-runtime/`。Windows 会生成 `.exe` 与 `-rg.exe`;macOS 构建还会同步 `node-pty` 所需的配套 spawn 辅助程序。 ## 验证 SDK @@ -79,11 +79,11 @@ pip install \ "dist-python/deepseek_harness_runtime_bin-$version-py3-none-macosx_14_0_arm64.whl" ``` -运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布四个平台 wheel 包:Linux x64、Linux arm64、macOS 14 或更高版本的 arm64,以及 Windows x64(`win_amd64`)。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 +运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布五个平台 wheel 包:Linux x64、Linux arm64、macOS 14 或更高版本的 arm64 与 x64,以及 Windows x64(`win_amd64`)。只有与仓库版本匹配时,才接受 `python-v` 标签;`0.0.1-rc.1` 之类的仓库预发布版本在 wheel 包文件名和元数据中使用规范化的 PEP 440 写法,例如 `0.0.1rc1`。 ## 验证候选发行版 -手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部五个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。该运行没有注册表凭据,dry-run 运行无法进入任何发布作业。 +手动运行 GitHub 的 `Release (Python)` 工作流并设置 `publish=false`,即可构建全部六个 wheel 包,在 Python 3.10 和 3.14 上安装 Linux 发行集合,检查精确文件名和元数据,执行 PyPI 默认单文件大小限制,并保留一份带 SHA-256 哈希的汇总产物。该运行没有注册表凭据,dry-run 运行无法进入任何发布作业。 公开发布从私有自动化仓库运行。包元数据指向独立的只读公开源码镜像,该镜像不运行发布 Actions。私有仓库把仓库变量 `PYPI_PUBLISHER_REPOSITORY` 定义为自身的 `owner/name`,并且只在有意发布期间把 `PUBLIC_PYPI_RELEASE_ENABLED` 从 `false` 改为 `true`。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 044ba3a728..410f250dd9 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/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 python/sdk-runtime/README.md -README.md: 28695259928a7edc6e6cf67e737f1012729df5a4 -README.zh.md: f23b253cfe47d9f1ae24568b51d9db810c7a4a9f +README.md: 050ae85d9b0a38b82a3c66a84c3d8f34e137be6c +README.zh.md: 7066d7224752294c25b58cfe8fb6a94524a2013d diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 2869525992..050ae85d9b 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -8,7 +8,7 @@ Platform runtime wheel for the DeepSeek Harness Python SDK. It packages the norm The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Python module. `dsh` forwards its arguments to the bundled executable and requires a non-empty `DSH_HOME`; it never falls back to `~/.dsh`. -Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory; Windows uses the `.exe` suffix. Linux and macOS wheels include a target-native `-rg` sidecar, Windows includes `-rg.exe`, and macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, macOS arm64, and Windows x64. The wheel tag and payload must match exactly; no Windows arm64 wheel is published. +Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory; Windows uses the `.exe` suffix. Linux and macOS wheels include a target-native `-rg` sidecar, Windows includes `-rg.exe`, and macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, macOS arm64, macOS x64, and Windows x64. The wheel tag and payload must match exactly; no Windows arm64 wheel is published. Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index f23b253cfe..7066d72247 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -8,7 +8,7 @@ DeepSeek Harness Python SDK 的平台运行时 wheel。它把普通 `dsh` CLI Wheel 会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 模块。`dsh` 将参数转发给内置可执行程序,并要求非空 `DSH_HOME`;它不会回退到 `~/.dsh`。 -生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`;Windows 使用 `.exe` 后缀。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序,Windows 包含 `-rg.exe`,macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64、macOS arm64 与 Windows x64。Wheel tag 必须与载荷严格匹配;不发布 Windows arm64 wheel。 +生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`;Windows 使用 `.exe` 后缀。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序,Windows 包含 `-rg.exe`,macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64、macOS arm64、macOS x64 与 Windows x64。Wheel tag 必须与载荷严格匹配;不发布 Windows arm64 wheel。 仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 与 sdist 均不包含它。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 38184d656e..97c1a6bc68 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,13 +14,14 @@ "@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-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:^", @@ -31,45 +33,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-projection": "workspace:^", @@ -79,6 +80,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:^", @@ -90,32 +93,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:^", @@ -125,8 +128,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/python/sdk-runtime/platforms.json b/python/sdk-runtime/platforms.json index 9c0a1fec72..66105877bd 100644 --- a/python/sdk-runtime/platforms.json +++ b/python/sdk-runtime/platforms.json @@ -11,6 +11,10 @@ "tag": "macosx_14_0_arm64", "executable": "deepseek-harness-sdk-runtime-macos-arm64" }, + "macos-x64": { + "tag": "macosx_14_0_x86_64", + "executable": "deepseek-harness-sdk-runtime-macos-x64" + }, "win-x64": { "tag": "win_amd64", "executable": "deepseek-harness-sdk-runtime-win-x64.exe" diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 2081aa5070..1fc5377e10 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -120,12 +120,11 @@ def _current_platform_tag() -> str: plat is None or arch is None or (plat == "win" and arch != "x64") - or (plat == "macos" and arch != "arm64") ): raise FileNotFoundError( "no bundled DeepSeek Harness SDK runtime exists for this platform " f"(sys.platform={sys.platform!r}, machine={platform.machine()!r}); supported: " - "Linux x64/arm64, macOS arm64, and Windows x64. " + _EXE_ACQUISITION_HINT + "Linux x64/arm64, macOS x64/arm64, and Windows x64. " + _EXE_ACQUISITION_HINT ) return f"{plat}-{arch}" diff --git a/python/sdk/tests/test_macos_deployment_target.py b/python/sdk/tests/test_macos_deployment_target.py index 8e68089a6e..e3a74c9e2e 100644 --- a/python/sdk/tests/test_macos_deployment_target.py +++ b/python/sdk/tests/test_macos_deployment_target.py @@ -16,8 +16,15 @@ checker = SimpleNamespace(**runpy.run_path(str(SCRIPT))) def test_otool_parser_uses_the_newest_macho_slice() -> None: output = """ +Load command 8 + cmd LC_VERSION_MIN_MACOSX + cmdsize 16 + version 10.7 + sdk 11.1 +Load command 9 cmd LC_BUILD_VERSION minos 11.0 +Load command 10 cmd LC_BUILD_VERSION minos 13.5 """ @@ -26,12 +33,34 @@ def test_otool_parser_uses_the_newest_macho_slice() -> None: def test_otool_parser_requires_a_deployment_target() -> None: - with pytest.raises(ValueError, match="contains no LC_BUILD_VERSION"): + with pytest.raises(ValueError, match="contains no macOS deployment target"): checker.parse_otool_deployment_target("Load command 0\n") +def test_otool_parser_ignores_unrelated_version_fields() -> None: + output = """ +Load command 1 + cmd LC_ID_DYLIB + cmdsize 48 + current version 14.1.0 +compatibility version 1.0.0 + """ + + with pytest.raises(ValueError, match="contains no macOS deployment target"): + checker.parse_otool_deployment_target(output) + + def test_wheel_tag_rejects_a_newer_executable_target() -> None: checker.ensure_compatible(Path("runtime"), (13, 5), "macosx_14_0_arm64") + checker.ensure_compatible(Path("runtime-x64"), (10, 7), "macosx_14_0_x86_64") with pytest.raises(RuntimeError, match="requires macOS 14.1"): checker.ensure_compatible(Path("spawn-helper"), (14, 1), "macosx_14_0_arm64") + + +def test_wheel_tag_accepts_only_supported_macos_architectures() -> None: + assert checker.claimed_version("macosx_14_0_arm64") == (14, 0) + assert checker.claimed_version("macosx_14_0_x86_64") == (14, 0) + + with pytest.raises(ValueError, match="unsupported macOS wheel platform tag"): + checker.claimed_version("macosx_14_0_universal2") diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 37d2a01734..9c87c5fe82 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -60,6 +60,8 @@ def test_pep440_version_spells_a_prerelease_the_python_way() -> None: def test_macos_wheel_tag_does_not_claim_unsupported_node_platforms() -> None: assert build_python_release.PLATFORMS["macos-arm64"][0] == "macosx_14_0_arm64" assert build_python_release.PLATFORMS["macos-arm64"][1] == "deepseek-harness-sdk-runtime-macos-arm64" + assert build_python_release.PLATFORMS["macos-x64"][0] == "macosx_14_0_x86_64" + assert build_python_release.PLATFORMS["macos-x64"][1] == "deepseek-harness-sdk-runtime-macos-x64" def test_windows_wheel_tag_and_payload_are_x64_only() -> None: @@ -95,7 +97,7 @@ def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: @pytest.mark.parametrize( ("target", "with_helper"), - [("linux-x64", False), ("macos-arm64", True), ("win-x64.exe", False)], + [("linux-x64", False), ("macos-arm64", True), ("macos-x64", True), ("win-x64.exe", False)], ) def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 0f06deb28e..3241fd3ce1 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -79,12 +79,11 @@ def test_current_platform_supports_windows_x64_only(monkeypatch: pytest.MonkeyPa runtime._current_platform_tag() -def test_current_platform_rejects_macos_x64(monkeypatch: pytest.MonkeyPatch) -> None: +def test_current_platform_supports_macos_x64(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(runtime.sys, "platform", "darwin") monkeypatch.setattr(runtime.platform, "machine", lambda: "x86_64") - with pytest.raises(FileNotFoundError, match="macOS arm64"): - runtime._current_platform_tag() + assert runtime._current_platform_tag() == "macos-x64" def test_runtime_requires_ripgrep_sidecar( diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts index 5f512a7703..0fe43528f2 100644 --- a/scripts/build-exe-for-python-sdk.spec.ts +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -87,6 +87,19 @@ describe('Python runtime executable builder CLI', () => { expect(result.stdout).not.toMatch(/pnpm\.cmd/i) }) + it('accepts the macOS x64 pkg target', () => { + const result = run( + { npm_execpath: 'C:\\tools\\pnpm.cjs' }, + '--skip-build', + '--dry-run', + '--targets=node24-macos-x64', + ) + + expect(result.status).toBe(0) + expect(result.stdout).toContain('exec pkg') + expect(result.stdout).toContain('--sea --targets node24-macos-x64') + }) + it('rejects a Windows arm64 product before any build step', () => { const result = run( { npm_execpath: 'C:\\tools\\pnpm.cjs' }, diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index f950cdaa1d..f58a526e64 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -207,7 +207,7 @@ class BuildCli { return [ 'Usage: pnpm exec tsx scripts/build-exe-for-python-sdk.ts [flags]', '', - ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64.', + ' --targets= pkg targets, e.g. node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64.', ' Default: the host platform only (on node24).', ' --skip-build skip `pnpm run build` (lib/ artifacts must already exist).', ' --dry-run print every command and config patch without executing.', diff --git a/scripts/check-macos-deployment-target.py b/scripts/check-macos-deployment-target.py index 633a3d7ced..7e3ba70fc7 100644 --- a/scripts/check-macos-deployment-target.py +++ b/scripts/check-macos-deployment-target.py @@ -12,7 +12,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] RELEASE = runpy.run_path(str(ROOT / "scripts" / "build-python-release.py")) -MACOS_PLATFORM_TAG = RELEASE["PLATFORMS"]["macos-arm64"][0] +MACOS_PLATFORMS = { + name: details[0] + for name, details in RELEASE["PLATFORMS"].items() + if name.startswith("macos-") +} def parse_version(value: str) -> tuple[int, ...]: @@ -24,7 +28,7 @@ def parse_version(value: str) -> tuple[int, ...]: def claimed_version(platform_tag: str) -> tuple[int, ...]: """Return the minimum macOS version encoded by a wheel platform tag.""" - match = re.fullmatch(r"macosx_(\d+)_(\d+)_arm64", platform_tag) + match = re.fullmatch(r"macosx_(\d+)_(\d+)_(?:arm64|x86_64)", platform_tag) if match is None: raise ValueError(f"unsupported macOS wheel platform tag: {platform_tag!r}") return int(match.group(1)), int(match.group(2)) @@ -32,12 +36,22 @@ def claimed_version(platform_tag: str) -> tuple[int, ...]: def parse_otool_deployment_target(output: str) -> tuple[int, ...]: """Return the newest deployment target from one or more Mach-O slices.""" - versions = [ - parse_version(match.group(1)) - for match in re.finditer(r"^\s*minos\s+(\d+(?:\.\d+)*)\s*$", output, re.MULTILINE) - ] + versions: list[tuple[int, ...]] = [] + command: str | None = None + for line in output.splitlines(): + stripped = line.strip() + if re.fullmatch(r"Load command \d+", stripped): + command = None + elif stripped == "cmd LC_BUILD_VERSION": + command = "build" + elif stripped == "cmd LC_VERSION_MIN_MACOSX": + command = "minimum" + elif command == "build" and (match := re.fullmatch(r"minos\s+(\d+(?:\.\d+)*)", stripped)): + versions.append(parse_version(match.group(1))) + elif command == "minimum" and (match := re.fullmatch(r"version\s+(\d+(?:\.\d+)*)", stripped)): + versions.append(parse_version(match.group(1))) if not versions: - raise ValueError("otool output contains no LC_BUILD_VERSION deployment target") + raise ValueError("otool output contains no macOS deployment target load command") return max(versions) @@ -73,7 +87,7 @@ def ensure_compatible( def validate_deployment_targets( - executables: list[Path], platform_tag: str = MACOS_PLATFORM_TAG + executables: list[Path], platform_tag: str ) -> list[tuple[Path, tuple[int, ...]]]: """Validate every executable and return its measured deployment target.""" measured = [(executable, deployment_target(executable)) for executable in executables] @@ -84,11 +98,13 @@ def validate_deployment_targets( def main() -> None: parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", choices=tuple(MACOS_PLATFORMS), required=True) parser.add_argument("executables", type=Path, nargs="+") args = parser.parse_args() - for executable, version in validate_deployment_targets(args.executables): + platform_tag = MACOS_PLATFORMS[args.platform] + for executable, version in validate_deployment_targets(args.executables, platform_tag): rendered = ".".join(str(part) for part in version) - print(f"{executable}: macOS {rendered} <= {MACOS_PLATFORM_TAG}") + print(f"{executable}: macOS {rendered} <= {platform_tag}") if __name__ == "__main__": diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 111372b79d..c3b1a2ccf5 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -370,7 +370,7 @@ describe('CI workflow', () => { name: 'python runtime / release-shaped matrix', uses: './.github/workflows/build-exe-for-python-sdk.yml', with: { - targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64', + targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64', ci: true, }, secrets: { @@ -462,7 +462,7 @@ describe('Python release workflows', () => { expect(build).toMatchObject({ uses: './.github/workflows/build-exe-for-python-sdk.yml', with: { - targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-win-x64', + targets: 'node24-linux-x64,node24-linux-arm64,node24-macos-arm64,node24-macos-x64,node24-win-x64', release: true, }, }) @@ -531,7 +531,7 @@ describe('Python release workflows', () => { const buildSteps: unknown[] = build.steps const manylinuxAddon = buildSteps.find(step => isRecord(step) && step.name === 'Rebuild Linux node-pty against manylinux 2.28') - const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS deployment target') + const macosCheck = buildSteps.find(step => isRecord(step) && step.name === 'Check macOS payload architecture and deployment target') const manylinuxSmoke = buildSteps.find(step => isRecord(step) && step.name === 'Run wheel in a manylinux 2.28 container') const cleanVenvPosix = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv (POSIX)') const cleanVenvWindows = buildSteps.find(step => isRecord(step) && step.name === 'Install local SDK and runtime wheels into a clean venv (Windows)') @@ -541,7 +541,8 @@ describe('Python release workflows', () => { const realApiPreflightWindows = buildSteps.find(step => isRecord(step) && step.name === 'Preflight installed-wheel real API test (Windows)') const installedRealApiPosix = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test (POSIX)') const installedRealApiWindows = buildSteps.find(step => isRecord(step) && step.name === 'Run installed-wheel real API black-box test (Windows)') - if (!isRecord(cleanVenvPosix) || !isRecord(cleanVenvWindows) + if (!isRecord(macosCheck) || typeof macosCheck.run !== 'string' + || !isRecord(cleanVenvPosix) || !isRecord(cleanVenvWindows) || !isRecord(installedKeylessPosix) || !isRecord(installedKeylessWindows) || !isRecord(realApiPreflightPosix) || !isRecord(realApiPreflightWindows) || !isRecord(installedRealApiPosix) || !isRecord(installedRealApiWindows)) { @@ -564,6 +565,9 @@ describe('Python release workflows', () => { expect(JSON.stringify(plan.steps)).toContain('pep440_version') const workflowJson = JSON.stringify(workflow) expect(workflowJson).toContain('macosx_14_0_arm64') + expect(workflowJson).toContain('macosx_14_0_x86_64') + expect(workflowJson).toContain('node24-macos-x64') + expect(workflowJson).toContain('macos-15-intel') expect(workflowJson).toContain('win_amd64') expect(workflowJson).toContain('node24-win-x64') expect(workflowJson).toContain('windows-2025') @@ -583,8 +587,10 @@ describe('Python release workflows', () => { expect(JSON.stringify(manylinuxAddon)).toContain('node-pty-glibc-versions.txt') expect(JSON.stringify(manylinuxAddon)).toContain('le 2.28') expect(macosCheck).toMatchObject({ if: "runner.os == 'macOS'" }) - expect(JSON.stringify(macosCheck)).toContain('scripts/check-macos-deployment-target.py') - expect(JSON.stringify(macosCheck)).toContain('$EXE-spawn-helper') + expect(macosCheck.run).toContain('scripts/check-macos-deployment-target.py') + expect(macosCheck.run).toContain('lipo "$payload" -verify_arch') + expect(macosCheck.run).toContain('$EXE-rg') + expect(macosCheck.run).toContain('$EXE-spawn-helper') expect(JSON.stringify(installedKeylessPosix)).toContain('--scenario all') expect(JSON.stringify(installedKeylessPosix)).toContain('env -u PYTHONPATH') expect(JSON.stringify(installedKeylessWindows)).toContain('--scenario all --installed-wheel') @@ -620,14 +626,29 @@ describe('Python release workflows', () => { } const runtimeScript: unknown[] = runtimeWheel.script const macosCheck = runtimeScript.find( - step => typeof step === 'string' && step.includes('PLATFORM" = macos-arm64'), + step => typeof step === 'string' && step.includes('${PLATFORM#macos-}'), ) if (typeof macosCheck !== 'string') { throw new TypeError('GitLab CI must check the macOS deployment target') } expect(macosCheck).toContain('scripts/check-macos-deployment-target.py') - expect(macosCheck).toContain('"$EXE" "$EXE-spawn-helper"') + expect(macosCheck).toContain('lipo "$payload" -verify_arch') + expect(macosCheck).toContain('"$EXE" "$EXE-rg" "$EXE-spawn-helper"') + }) + + it('builds the macOS x64 wheel on the matching GitLab runner', () => { + const workflow = loadWorkflow('.gitlab-ci.yml') + const macosX64 = workflow['runtime-macos-x64'] + const publish = workflow['publish-python'] + if (!isRecord(macosX64) || !isRecord(publish) || !Array.isArray(publish.needs)) { + throw new TypeError('GitLab CI must define the macOS x64 runtime and publication jobs') + } + + expect(macosX64.tags).toEqual(['macos-x64']) + expect(macosX64.variables).toMatchObject({ PKG_TARGET: 'node24-macos-x64', PLATFORM: 'macos-x64' }) + expect(publish.needs).toContainEqual({ job: 'runtime-macos-x64', artifacts: true }) + expect(JSON.stringify(publish.script)).toContain('macosx_14_0_x86_64.whl') }) it('builds and black-box tests the Windows x64 wheel in GitLab', () => { @@ -683,6 +704,38 @@ describe('Issue lifecycle workflow', () => { const policyPullRequest = workflowEvent(policy, 'pull_request') expect(policyPullRequest.types).toContain('ready_for_review') }) + + it('uses a read-only Project token only for human 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') + const humanPullRequest = + "${{ github.event.pull_request.user.type != 'Bot' && github.event.pull_request.user.type != 'App' }}" + + expect(tokenStep).toMatchObject({ + id: 'app-token', + if: humanPullRequest, + 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-issues': 'read', + 'permission-organization-projects': 'read', + }, + }) + expect(validateStep).toMatchObject({ + if: humanPullRequest, + env: { + GITHUB_TOKEN: '${{ github.token }}', + PROJECT_TOKEN: '${{ steps.app-token.outputs.token }}', + }, + }) + }) }) describe('npm release workflows', () => { diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 4758a66f3c..78f1a17a44 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -184,7 +184,7 @@ describe('gate graph validation', () => { expect(ids).toEqual([ 'rescope-vendor', 'publint', 'constraints', 'package-dependencies', '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({ diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index fe1b78fcec..47898333c8 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -304,6 +304,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' }), ] } @@ -699,6 +700,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/test-proxy-environment.spec.ts b/scripts/test-proxy-environment.spec.ts new file mode 100644 index 0000000000..8430f6436d --- /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/util/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..f8111ae689 --- /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/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' + +/** 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/scripts/verify-no-bare-dispatcher.spec.ts b/scripts/verify-no-bare-dispatcher.spec.ts new file mode 100644 index 0000000000..ce6bbe6ec7 --- /dev/null +++ b/scripts/verify-no-bare-dispatcher.spec.ts @@ -0,0 +1,143 @@ +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(` + import { Agent } from 'undici' + const dispatcher = new Agent({ connect: { lookup } }) + const response = await fetch(url, { dispatcher }) + `)).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\`']) + }) + + it('rejects a namespaced construction', () => { + 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', () => { + 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({})' }, + ]) + }) + + it('accepts the sanctioned factory', () => { + expect(reasons(' const route = proxyRouteFor(url)')).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('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' + 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(` + 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("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("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 new file mode 100644 index 0000000000..2b08dea968 --- /dev/null +++ b/scripts/verify-no-bare-dispatcher.ts @@ -0,0 +1,226 @@ +/** + * 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. + * + * `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 + * 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' +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/util/http-proxy/' + +/** + * 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:' + +/** Undici agent classes whose construction selects a transport, under any local name. */ +const AGENT_EXPORTS = new Set(['Agent', 'ProxyAgent', 'EnvHttpProxyAgent']) + +/** 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 position broke. */ + readonly what: string + /** The offending source text, trimmed. */ + readonly text: string +} + +/** + * 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. + */ +function agentBindings(source: ts.SourceFile): { agents: Set; namespaces: Set } { + const agents = new Set() + const namespaces = new Set() + 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 } +} + +/** + * 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 position, in source order. + */ +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') + const violations: DispatcherViolation[] = [] + + 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, 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'))) +} + +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(` ${relative('.', violation.file)}:${String(violation.line)} ${violation.what}`) + console.error(` ${violation.text}`) + } + 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) +} + +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 b87ca86feb..565693d35f 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -64,6 +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/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-runtime-closure.spec.ts b/scripts/verify-runtime-closure.spec.ts index 90dbf20799..6d1e8e3edb 100644 --- a/scripts/verify-runtime-closure.spec.ts +++ b/scripts/verify-runtime-closure.spec.ts @@ -21,6 +21,7 @@ const platforms = { 'linux-x64': { tag: 'manylinux_2_28_x86_64', executable: 'runtime-linux-x64' }, 'linux-arm64': { tag: 'manylinux_2_28_aarch64', executable: 'runtime-linux-arm64' }, 'macos-arm64': { tag: 'macosx_14_0_arm64', executable: 'runtime-macos-arm64' }, + 'macos-x64': { tag: 'macosx_14_0_x86_64', executable: 'runtime-macos-x64' }, 'win-x64': { tag: 'win_amd64', executable: 'runtime-win-x64.exe' }, } @@ -64,7 +65,7 @@ describe('verifyRuntimeClosure', () => { expect(result.presetCount).toBe(1) expect(result.failures).toEqual([ 'standard preset -> @scope/linux (linux-arm64, linux-x64)', - 'standard preset -> @scope/macos (macos-arm64)', + 'standard preset -> @scope/macos (macos-arm64, macos-x64)', 'standard preset -> @scope/windows (win-x64)', ]) }) @@ -83,7 +84,7 @@ describe('verifyRuntimeClosure', () => { const result = await verifyRuntimeClosure(root) expect(result.failures).toEqual([ - 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64, win-x64)', + 'standard preset -> @scope/conditional (linux-arm64, linux-x64, macos-arm64, macos-x64, win-x64)', ]) }) @@ -117,7 +118,7 @@ describe('verifyRuntimeClosure', () => { const result = await verifyRuntimeClosure(root) expect(result.failures).toEqual([ - 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64, win-x64)', + 'standard preset -> @scope/plugin [runtime dependency is "1.2.3"; expected workspace:] (linux-arm64, linux-x64, macos-arm64, macos-x64, win-x64)', ]) }) diff --git a/snapshots/session/fs-policy-reject/session.jsonl b/snapshots/session/fs-policy-reject/session.jsonl index 8245991471..1e050218e3 100644 --- a/snapshots/session/fs-policy-reject/session.jsonl +++ b/snapshots/session/fs-policy-reject/session.jsonl @@ -21,7 +21,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."},{"type":"tool-call","id":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:3}}"},"usage":{"inputTokens":3132,"outputTokens":115,"cacheReadTokens":0,"reasoningTokens":36}},"sourceEventSeqs":[12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}],"isError":true}],"role":"user","id":"{{message:4}}"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[85],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_x0zlnXl5JOxLrAYL9y7P0119"},"content":[{"type":"tool-result","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","content":[{"type":"text","text":"Error: cannot modify \"{{cwd}}/settings.txt\": file has not been read — read the file, then retry"}],"isError":true}],"role":"user","id":"{{message:4}}"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[85],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -34,7 +34,7 @@ {"type":"assistant/chunk","data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."},{"type":"tool-call","id":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":281,"outputTokens":119,"cacheReadTokens":3200,"reasoningTokens":40}},"sourceEventSeqs":[89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164],"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":2,"callId":"call_00_GVknJu2tksKkP4lALCwh0926","name":"edit","arguments":"{\"file_path\": \"settings.txt\", \"old_string\": \"blue\", \"new_string\": \"green\"}"}} -{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}],"isError":true}],"role":"user","id":"{{message:6}}"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[166],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_GVknJu2tksKkP4lALCwh0926"},"content":[{"type":"tool-result","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","content":[{"type":"text","text":"Error: cannot modify \"{{cwd}}/settings.txt\": file has not been read — read the file, then retry"}],"isError":true}],"role":"user","id":"{{message:6}}"},"error":{"name":"FsError","code":"FS_NOT_OBSERVED"}},"sourceEventSeqs":[166],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":2}} {"type":"step/start","data":{"turn":1,"step":3}} {"type":"assistant/chunk","data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} diff --git a/tsconfig.base.json b/tsconfig.base.json index b6daa12c06..68aab92144 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -285,6 +285,7 @@ "@deepseek-ai/dsh-hook-protocol/invariant": ["./packages/hooks/hook-protocol/src/invariant.ts"], "@deepseek-ai/dsh-hooks-claude-code": ["./packages/hooks/hooks-claude-code/src"], "@deepseek-ai/dsh-hooks-codex": ["./packages/hooks/hooks-codex/src"], + "@deepseek-ai/dsh-http-proxy": ["./packages/util/http-proxy/src"], "@deepseek-ai/dsh-jobs": ["./packages/jobs/jobs/src"], "@deepseek-ai/dsh-jobs/invariant": ["./packages/jobs/jobs/src/invariant.ts"], "@deepseek-ai/dsh-jobs-local": ["./packages/jobs/jobs-local/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 6dfe0a9d31..d2a033266d 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -128,6 +128,7 @@ { "path": "./vendor/hmr" }, { "path": "./vendor/logger-console" }, { "path": "./packages/util/brand" }, + { "path": "./packages/util/http-proxy" }, { "path": "./packages/util/launch-environment" }, { "path": "./packages/util/native-command" }, { "path": "./packages/util/home-paths" }, diff --git a/vitest.config.ts b/vitest.config.ts index 39b944664b..a67ca75f8b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -150,7 +150,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, @@ -166,7 +166,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, @@ -181,7 +181,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 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',