mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(python-sdk): harden profile runtime startup
Resolve packaged profile proxies with Node ESM import conditions from each package installation, and fail loud when an explicit runtime export or legacy main entry is missing. Serialize the shared profile fallback under the existing cross-process writer lock so concurrent dsh processes cannot observe partial proxies; either carrier now replaces the other carrier’s managed entry without manual cleanup. Give Python initialize its own 10-second default bound and name the selected profile in timeout diagnostics, while leaving ordinary agent turns unbounded by default. Package the dynamically resolved web frontend and skill-badge assets so the runtime wheel’s normal dsh profiles do not depend on pkg static-discovery accidents. Rewrite the root launch rule and every active stale SDK-runtime note to the shipped dsh profile architecture in both languages. Focused tests prove import-only and transitive package exports, lock contention, cross-carrier transitions, missing-entry failures, asset inventory, and bounded initialization.
This commit is contained in:
+2
-2
@@ -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: 50e63bbc4f664f9450f01acf45888991a27517d2
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: cf92f42f508f005c64f09aa0127b28ed162cede5
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.md: e46dbbc119e2078e44632d81b333c8be5ab9d6d7
|
||||
2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7a1e3f445ea1c1f03df2b349a4391534a5502c3
|
||||
|
||||
+5
-5
@@ -36,27 +36,27 @@ The Python client supplies an explicit Harness home and selects the `sdk` profil
|
||||
|
||||
Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails.
|
||||
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-sdk-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`.
|
||||
|
||||
The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call.
|
||||
|
||||
### Build pipeline and artifacts
|
||||
|
||||
[`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-sdk-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/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg-<platform>-<arch>` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. 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`. CI treats these products as intermediate test inputs and retains their platform wheels. 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.
|
||||
[`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/` → 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 → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover 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-<platform>-<arch>` under `dist-exe/` and copy it into the runtime directory. 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 three 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` and the `build-exe` label can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, 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 three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three 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<repository-version>` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal.
|
||||
|
||||
### Python SDK distribution: two carriers, exe for production, node for development
|
||||
|
||||
The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe with its required `-rg` sidecar and optional macOS helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions.
|
||||
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<repository-version>` 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 `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms.
|
||||
|
||||
The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-sdk-jsonrpc-server` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`.
|
||||
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.
|
||||
|
||||
### Naming lineage
|
||||
|
||||
`@deepseek-ai/dsh-sdk-python-runtime` (the private carrier) → `dsh-sdk-python-runtime-closure` (the deploy manifest; no scope prefix, so it is not a dsh release package) → `dsh-jsonrpc-agent-pkg-<platform>-<arch>` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`.
|
||||
`dsh-python-runtime-closure` is the private deploy manifest and `deepseek-harness-sdk-runtime-<platform>-<arch>` is the executable family. The wire `serverInfo.name` is `deepseek-harness-sdk-runtime`; the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules are `deepseek_harness` / `deepseek_harness_runtime`.
|
||||
|
||||
## Disposition of worker-style plugins
|
||||
|
||||
|
||||
+5
-5
@@ -36,27 +36,27 @@ Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 pa
|
||||
|
||||
exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。
|
||||
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-sdk-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。
|
||||
|
||||
部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。
|
||||
|
||||
### 构建流水线与产物
|
||||
|
||||
[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-sdk-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/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `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 不会从注册表解析这些未发布名称。
|
||||
[`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/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime-<platform>-<arch>` 写入 `dist-exe/` 并拷回运行时目录。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` 与 `build-exe` 标签仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.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 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v<repository-version>` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。
|
||||
|
||||
### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发
|
||||
|
||||
Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-python-runtime/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。
|
||||
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<repository-version>` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,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` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。
|
||||
|
||||
exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-sdk-jsonrpc-server` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。
|
||||
Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。
|
||||
|
||||
### 命名血统
|
||||
|
||||
`@deepseek-ai/dsh-sdk-python-runtime`(私有载体)→ `dsh-sdk-python-runtime-closure`(部署 manifest;没有作用域前缀,因此不属于 dsh 发布包)→ `dsh-jsonrpc-agent-pkg-<platform>-<arch>`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。
|
||||
`dsh-python-runtime-closure` 是私有部署 manifest,`deepseek-harness-sdk-runtime-<platform>-<arch>` 是可执行文件族。协议字段 `serverInfo.name` 是 `deepseek-harness-sdk-runtime`;Python 分发包名是 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名是 `deepseek_harness` / `deepseek_harness_runtime`。
|
||||
|
||||
## 工作线程插件
|
||||
|
||||
|
||||
+2
-2
@@ -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: 69188e806d9192d230d1f1b52daf27a3b30481be
|
||||
2026-08-22-single-dsh-application-launcher.zh.md: a6bb1909b019ea0d386bd2f4a1bb8f5199ef1974
|
||||
2026-08-22-single-dsh-application-launcher.md: 27ec766dc62a203a3054ec67828b8897ab5ad245
|
||||
2026-08-22-single-dsh-application-launcher.zh.md: 5fb7fb4234b093a6f69446e0d9b1d7fbb7f46cf3
|
||||
|
||||
+7
-11
@@ -8,7 +8,7 @@ English | [中文](2026-08-22-single-dsh-application-launcher.zh.md)
|
||||
|
||||
DeepSeek Harness application processes need one owner for composition, plugin resolution, environment discovery, shutdown, and user customization. A dedicated app bin with a complete `cordis.yml` creates a second lifecycle beside profile launch: plugins installed into a profile do not reach it, behavior drifts from `dsh-base`, and SDK callers learn arbitrary process argv instead of the product's composition model.
|
||||
|
||||
The Python SDK distributes a native executable and three platform wheels whose embedded direct-config runtime cannot change launch architecture without rebuilding and validating the complete VFS closure. That distribution needs an explicit temporary exception, not a second general Node application pattern.
|
||||
The Python SDK distributes a native executable and three platform wheels. Its packaged process must use the same profile launcher while preserving the closed VFS dependency tree, native sidecars, and installed-wheel evidence.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -43,19 +43,15 @@ SDK users customize plugins through profiles. `dsh plugin --profile <name> ...`
|
||||
|
||||
Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inherited `DSH_HOME`, then `~/.dsh`. `subagent-dsh-sdk` instead requires an explicit absolute home, so a nested runtime cannot discover a person's profiles, installed plugins, credentials, or sessions through the operating-system home. DSH-specific ACP child examples also pass an isolated home; the ACP backend itself remains generic for non-DSH agents.
|
||||
|
||||
### Python exception and names
|
||||
### Python runtime
|
||||
|
||||
The Python SDK's direct-config application lives in the private `packages/sdk/python-runtime` package named `@deepseek-ai/dsh-sdk-python-runtime`. Its only packaged executable entry is `lib/packaged-bin.js`, consumed by the private `dsh-sdk-python-runtime-closure` deploy root. It has no public npm bin. The runnable direct Python example is `examples/python-sdk-agent`.
|
||||
The Python runtime wheel packages the ordinary `@deepseek-ai/dsh` CLI from `node_modules/@deepseek-ai/dsh/lib/bin.js` through the private `dsh-python-runtime-closure` deploy manifest. The Python client selects `dsh --profile sdk`, ordered patch files, and an explicit Harness home; the installed `dsh` console command exposes the same profile grammar. The runnable Python example is `examples/python-sdk-agent`.
|
||||
|
||||
Python-observable behavior remains fixed: Python API, SDK wire, default `cordis.yml`, environment variables, wheel distribution names, packaged executable names, sidecar names, explicit runtime options, zero-config behavior, and supported platforms. The stable SDK family remains `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, `@deepseek-ai/dsh-sdk-jsonrpc-server`, and wire identity `deepseek-harness-sdk-runtime`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias.
|
||||
The executable family is `deepseek-harness-sdk-runtime-<platform>-<arch>`. 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.
|
||||
|
||||
### Enforcement
|
||||
|
||||
`verify-application-entrypoints` scans application/package manifests, executable sources, and root demo scripts. The allowlist classifies the `dsh` product bin, vendor-excluded scope, the private WebWorker build tool, test support, and the private Python carrier. An unclassified shebang, a new package bin, or a demo wrapper that bypasses `apps/cli/src/bin.ts` fails hygiene and the primary/static CI aggregates.
|
||||
|
||||
## Deferred Python migration
|
||||
|
||||
The Python runtime follow-up must move the packaged process through `dsh --profile sdk`, preserve the wheel's closed dependency and native sidecar behavior, and delete `@deepseek-ai/dsh-sdk-python-runtime`. Only after those conditions pass on Linux x64, Linux arm64, and macOS arm64 does the executable family change from `dsh-jsonrpc-agent-pkg-<platform>-<arch>` to `deepseek-harness-sdk-runtime-<platform>-<arch>`. The temporary carrier and current artifact names make that obligation visible without weakening current Python compatibility.
|
||||
`verify-application-entrypoints` scans application/package manifests, executable sources, and root demo scripts. The allowlist classifies the `dsh` product bin, vendor-excluded scope, the private WebWorker build tool, and test support. An unclassified shebang, a new package bin, or a demo wrapper that bypasses `apps/cli/src/bin.ts` fails hygiene and the primary/static CI aggregates.
|
||||
|
||||
## Existing decisions and supersession
|
||||
|
||||
@@ -88,7 +84,7 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o
|
||||
- Focused unit suites cover profile launch resolution, initialization bounds, SDK retries, server readiness, and nested isolated homes with 100% coverage on the changed runtime sources.
|
||||
- Keyless ACP and SDK snapshots boot real `dsh` profiles and pin protocol output plus persisted logs; the nested SDK composition boots a second real profile runtime.
|
||||
- The real-API workflow caps file parallelism at four because one profile e2e file can own several complete `dsh` subprocess trees; workflow tests pin that resource bound.
|
||||
- The Python suite exercises exe and node carriers; all packaged-runtime scenarios, native macOS executable construction, both wheels, and clean-wheel default/MCP smokes retain the existing artifact names.
|
||||
- The Python suite exercises exe and node carriers; packaged-runtime scenarios, native macOS executable construction, both wheels, and clean-wheel default/MCP smokes pin the `deepseek-harness-sdk-runtime-*` artifacts and profile launch.
|
||||
- `verify-application-entrypoints` includes invalid fixtures for package bins, executable sources, package-launching demo wrappers, and unclassified demos.
|
||||
|
||||
## Consequences
|
||||
@@ -98,4 +94,4 @@ The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-o
|
||||
- SDK and ACP share the complete base application and one set of policy and tools; snapshots present intentional assembled differences explicitly.
|
||||
- Adding `@deepseek-ai/dsh` increases the TypeScript client's install size in exchange for a deterministic same-version runtime.
|
||||
- Trusted user patches can add a plugin that writes to stdout and corrupt their own protocol stream; shipped profiles guarantee purity, not arbitrary third-party composition.
|
||||
- Python keeps a visibly private, narrowly allowed direct-config carrier until its platform artifact migration is independently proven.
|
||||
- Python packages the ordinary `dsh` profile launcher while retaining a closed native runtime and no system-Node requirement for wheel users.
|
||||
|
||||
+7
-11
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
DeepSeek Harness 应用进程需要由同一个机制负责组合、插件解析、环境发现、关闭和用户自定义。带完整 `cordis.yml` 的专用应用 bin 会在 profile 启动之外形成第二套生命周期:安装到 profile 的插件无法到达它,行为会与 `dsh-base` 偏离,SDK 调用方还需要学习任意进程 argv,而不是产品的组合模型。
|
||||
|
||||
Python SDK 分发一个原生可执行文件和三个平台 wheel 包;其中嵌入的直读配置运行时只有在重建并验证完整 VFS 闭包后才能改变启动架构。该分发需要一个明确的临时例外,而不是另一种通用 Node 应用模式。
|
||||
Python SDK 分发一个原生可执行文件和三个平台 wheel 包。其打包进程必须使用同一 profile 启动器,同时保留封闭的 VFS 依赖树、原生伴随文件与 installed-wheel 证据。
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -43,19 +43,15 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile <name> ...` 管
|
||||
|
||||
直接使用 SDK 时遵循普通 Harness home 解析:显式 `dshHome`、继承的 `DSH_HOME`,最后是 `~/.dsh`。`subagent-dsh-sdk` 则要求显式绝对 home,因此嵌套运行时不会通过操作系统 home 发现个人 profile、已安装插件、凭据或会话。DSH 专用 ACP 子进程示例同样传入隔离 home;ACP 后端自身继续适用于非 DSH agent。
|
||||
|
||||
### Python 例外与命名
|
||||
### Python 运行时
|
||||
|
||||
Python SDK 的直读配置应用位于私有 `packages/sdk/python-runtime` 包,名称是 `@deepseek-ai/dsh-sdk-python-runtime`。它唯一的打包可执行入口是 `lib/packaged-bin.js`,由私有 `dsh-sdk-python-runtime-closure` 部署根消费。它没有公开 npm bin。可运行的直启 Python 示例是 `examples/python-sdk-agent`。
|
||||
Python 运行时 wheel 通过私有 `dsh-python-runtime-closure` 部署 manifest,打包来自 `node_modules/@deepseek-ai/dsh/lib/bin.js` 的普通 `@deepseek-ai/dsh` CLI。Python 客户端选择 `dsh --profile sdk`、有序 patch 文件和显式 Harness home;安装的 `dsh` 控制台命令暴露相同 profile 语法。可运行的 Python 示例是 `examples/python-sdk-agent`。
|
||||
|
||||
Python 可观察行为保持不变:Python API、SDK 协议格式、默认 `cordis.yml`、环境变量、wheel 包分发名称、打包可执行文件名称、伴随文件名称、显式运行时选项、零配置行为与支持平台。稳定 SDK 包族继续是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol`、`@deepseek-ai/dsh-sdk-jsonrpc-server`,协议 identity 继续是 `deepseek-harness-sdk-runtime`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。
|
||||
可执行文件族是 `deepseek-harness-sdk-runtime-<platform>-<arch>`。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 启动别名。
|
||||
|
||||
### 强制校验
|
||||
|
||||
`verify-application-entrypoints` 扫描应用/包 manifest、可执行源码和根 demo 脚本。允许清单对 `dsh` 产品 bin、排除的 vendor 范围、私有 WebWorker 构建工具、测试支持以及私有 Python 载体进行分类。未分类的 shebang、新包 bin 或绕过 `apps/cli/src/bin.ts` 的 demo wrapper 都会使 hygiene 与 primary/static CI 聚合失败。
|
||||
|
||||
## 暂缓的 Python 迁移
|
||||
|
||||
Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk`,保持 wheel 包的封闭依赖与原生伴随文件行为,并删除 `@deepseek-ai/dsh-sdk-python-runtime`。只有这些条件在 Linux x64、Linux arm64 与 macOS arm64 全部通过后,可执行文件族才会从 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 改名为 `deepseek-harness-sdk-runtime-<platform>-<arch>`。临时载体与当前产物名称使这项义务清晰可见,同时不削弱当前 Python 兼容性。
|
||||
`verify-application-entrypoints` 扫描应用/包 manifest、可执行源码和根 demo 脚本。允许清单对 `dsh` 产品 bin、排除的 vendor 范围、私有 WebWorker 构建工具和测试支持进行分类。未分类的 shebang、新包 bin 或绕过 `apps/cli/src/bin.ts` 的 demo wrapper 都会使 hygiene 与 primary/static CI 聚合失败。
|
||||
|
||||
## 既有决策与取代关系
|
||||
|
||||
@@ -88,7 +84,7 @@ Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk`
|
||||
- 聚焦单元套件覆盖 profile 启动解析、初始化时限、SDK 重试、服务器就绪和嵌套隔离 home,并对变更后的运行时源码实现 100% 覆盖率。
|
||||
- 免密钥 ACP 与 SDK 快照启动真实 `dsh` profile,并钉住协议输出与持久化日志;嵌套 SDK 组合会启动第二个真实 profile 运行时。
|
||||
- 真实 API 工作流把文件并行度限制为 4,因为一个 profile e2e 文件可能拥有多个完整 `dsh` 子进程树;工作流测试会钉住该资源上限。
|
||||
- Python 套件同时测试 exe 与 node 载体;全部打包运行时场景、原生 macOS 可执行文件构建、两个 wheel 包以及干净 wheel 默认/MCP 冒烟测试都保留既有产物名称。
|
||||
- Python 套件同时测试 exe 与 node 载体;打包运行时场景、原生 macOS 可执行文件构建、两个 wheel 包以及干净 wheel 默认/MCP 冒烟测试会钉住 `deepseek-harness-sdk-runtime-*` 产物与 profile 启动。
|
||||
- `verify-application-entrypoints` 包含包 bin、可执行源码、直启包的 demo wrapper 与未分类 demo 等非法 fixture(测试前置数据)。
|
||||
|
||||
## 影响
|
||||
@@ -98,4 +94,4 @@ Python 运行时后续工作必须把打包进程迁移到 `dsh --profile sdk`
|
||||
- SDK 与 ACP 共享完整 base 应用和同一份策略与工具;快照以显式差异呈现刻意采用的组装变化。
|
||||
- 增加 `@deepseek-ai/dsh` 会扩大 TypeScript 客户端的安装体积,换来确定的同版本运行时。
|
||||
- 受信任用户 patch 可以增加写入 stdout 的插件并破坏自己的协议流;随附 profile 保证纯净,不为任意第三方组合提供保证。
|
||||
- Python 保留一个清晰可见的私有直读配置载体,直到其平台产物迁移得到独立证明。
|
||||
- Python 打包普通 `dsh` profile 启动器,同时保留封闭原生运行时,wheel 用户无需系统 Node。
|
||||
|
||||
+2
-2
@@ -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: 400915155460598e754c2f81d60b26e693ff438a
|
||||
2026-08-23-python-sdk-dsh-profile-runtime.zh.md: a3c9074817d1c1e9b3f59fbe5fb02910d20e02f4
|
||||
2026-08-23-python-sdk-dsh-profile-runtime.md: 14c58c85923aac1b54362c72f61f7f1e1c145124
|
||||
2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 9f584ca9bfcc7907caede70ef7e9781f2bc37aab
|
||||
|
||||
@@ -16,13 +16,13 @@ A normal profile cannot be adopted only at the Python wrapper. The runtime execu
|
||||
|
||||
The runtime executable packages `@deepseek-ai/dsh` and runs its ordinary command grammar. The Python client selects `--profile sdk` by default, forwards ordered absolute `--patch` paths, and may select another `dsh` executable or profile. The private `@deepseek-ai/dsh-sdk-python-runtime` application package and checked-in runtime `cordis.yml` do not exist. JSON-RPC serving remains the `@deepseek-ai/dsh-sdk-app` bundle and `@deepseek-ai/dsh-sdk-jsonrpc-server` plugin, not a Python-owned boot path.
|
||||
|
||||
The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, and timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path.
|
||||
The public Python configuration is `dsh_bin`, `profile`, ordered `patches`, `dsh_home`, process cwd/environment, provider/model/token selection, a bounded initialization timeout, and optional turn/shutdown timeouts. It does not expose a complete Cordis tree or arbitrary launch argv. `RunResult` reports the protocol-owned run values and does not duplicate the profile's persistence path.
|
||||
|
||||
Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME` in the child environment. The SDK never discovers `~/.dsh`. The selected home consistently owns profiles, external plugins, credentials, settings, and sessions.
|
||||
|
||||
### Plugin customization
|
||||
|
||||
Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback.
|
||||
Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile sdk ...` manages external dependencies and bundle order, `$DSH_HOME/profiles/sdk/cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A different profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile.
|
||||
|
||||
The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`.
|
||||
|
||||
@@ -30,7 +30,7 @@ The runtime wheel installs a `dsh` console command. Ordinary profile and SDK exe
|
||||
|
||||
The zero-code deployment manifest is `dsh-python-runtime-closure`. It packages `node_modules/@deepseek-ai/dsh/lib/bin.js` and profile, bundle, preset, native-addon, and shared-library assets into `deepseek-harness-sdk-runtime-<platform>-<arch>`. The wheel distribution names, Python import modules, JSON-RPC messages, and wire-stable `serverInfo.name = deepseek-harness-sdk-runtime` remain unchanged.
|
||||
|
||||
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 mirrors the source package's explicit runtime exports and identity and re-exports its virtual module URLs. 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.
|
||||
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 runtime exports under ESM import conditions and re-exports its virtual module URLs. One cross-process writer lock serializes fallback healing, preventing partial proxy visibility and allowing either carrier to 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, and macOS arm64. 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.
|
||||
|
||||
|
||||
+3
-3
@@ -16,13 +16,13 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml
|
||||
|
||||
运行时可执行程序打包 `@deepseek-ai/dsh` 并运行其普通命令语法。Python 客户端默认选择 `--profile sdk`,转发有序绝对 `--patch` 路径,也可以选择另一个 `dsh` 可执行程序或 profile。私有 `@deepseek-ai/dsh-sdk-python-runtime` 应用包和检入的运行时 `cordis.yml` 均不存在。JSON-RPC 服务仍由 `@deepseek-ai/dsh-sdk-app` bundle 与 `@deepseek-ai/dsh-sdk-jsonrpc-server` 插件提供,而不是 Python 自有启动路径。
|
||||
|
||||
公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择与 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。
|
||||
公开 Python 配置包括 `dsh_bin`、`profile`、有序 `patches`、`dsh_home`、进程 cwd/环境、provider/model/token 选择、有界初始化 timeout,以及可选的轮次/关闭 timeout。它不暴露完整 Cordis 树或任意启动 argv。`RunResult` 报告协议所有的运行值,不重复 profile 的持久化路径。
|
||||
|
||||
每次 Python 启动都要求显式 `dsh_home`,或子进程环境中的非空 `DSH_HOME`。SDK 绝不会发现 `~/.dsh`。所选 home 统一拥有 profile、外部插件、凭据、设置与会话。
|
||||
|
||||
### 插件自定义
|
||||
|
||||
持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退。
|
||||
持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile sdk ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles/sdk/cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。另一个 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。
|
||||
|
||||
运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。
|
||||
|
||||
@@ -30,7 +30,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml
|
||||
|
||||
零代码部署 manifest 是 `dsh-python-runtime-closure`。它把 `node_modules/@deepseek-ai/dsh/lib/bin.js` 以及 profile、bundle、preset、原生 addon 与共享库资源打包进 `deepseek-harness-sdk-runtime-<platform>-<arch>`。Wheel distribution 名称、Python import 模块、JSON-RPC 消息和协议稳定的 `serverInfo.name = deepseek-harness-sdk-runtime` 保持不变。
|
||||
|
||||
普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理镜像源包的显式运行时 exports 与身份,并重新导出其虚拟模块 URL。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。
|
||||
普通 Node profile 在 `$DSH_HOME/profiles/node_modules` 中使用符号链接,让外部插件共享安装包。操作系统符号链接无法进入 pkg 的 `/snapshot` 文件系统,因此打包 CLI 改为写入小型真实 ESM 代理包。每个代理按 ESM import 条件解析源包的显式运行时 exports,并重新导出其虚拟模块 URL。一把跨进程写入锁会串行执行后备修复,避免暴露未完整写入的代理,并允许任一载体替换另一载体留下的受管条目。Loader 配置项和外部插件 peer 因而可以通过普通 profile 逐级向上查找解析,同时保留一个 Cordis 和每个内置模块的单一实例。
|
||||
|
||||
已发布目标集合是 Linux x64、Linux arm64 与 macOS arm64。Installed-wheel 黑盒 CI 在每个目标上负责产物来源、默认及 patched profile、外部 bundle 安装、原生工具、MCP、直接 JSON-RPC、快照,以及可信真实提供方轮次。
|
||||
|
||||
|
||||
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: eda9d3a3de91944a298070d6cc22f632294f7a28
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: 1a72c6b1cdb7453b8468d6e6be37aec76323f714
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.md: 37c341e964b556c7ab5fdd9081416883066b97d1
|
||||
2026-07-27-typescript-sdk-and-sdk-subagent-backend.zh.md: e97e951028de3bcda9fe11be0351072481c72dd9
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ Three packages, layered exactly like the existing Python stack, plus one Service
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`** (`packages/subagent/subagent-dsh-sdk/`) — the second out-of-process `SubagentProvider`, structured as `subagent-acp`'s sibling: same all-false capabilities and `inheritsParentContext: false`, same publish-after-handshake ownership transaction, same result-never-rejects flattening through an `onError` sink, same parent-namespace run id. The child answer is read from streamed `session.event`s — the last complete `assistant/message`, else accumulated `text-delta` chunks, so partial answers survive cancellation. Stop reasons map from the child's structured `TurnEndReason` (`completed`/`max-tokens`/`aborted` pass through; everything else, including a settled-without-turn child, is `error`). Its `dshBin`/profile/patch/home config selects an isolated SDK application, `provider`/`model` feeds the child's `initialize`, and `env` supplies explicit child-only values such as its API key.
|
||||
- **The subagent seam grows `out-of-process.ts`**: the provider-side vocabulary both out-of-process backends share — `NO_START_CAPABILITIES`, timing-bound validation, child cwd resolution (config override, else the delegating parent session's workspace), the never-reject `settleRunResult`, and the `subprocessRunHandle` publication. Process mechanics (spawn, env scrub, tree-scoped teardown) live in the `dsh-subprocess` seam; `subagent-acp` spawns through `ctx.subprocess`, while this backend spawns through the SDK client (the subprocess README's documented exception for SDK-managed transports) and applies the seam's `scrubbedParentEnv()` itself.
|
||||
|
||||
`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical); the private `@deepseek-ai/dsh-sdk-python-runtime` carrier consumes the shared protocol through its packaged closure.
|
||||
`dsh-sdk-jsonrpc-server` keeps serving unchanged (the wire is byte-identical). TypeScript and Python clients both consume the shared protocol through `dsh --profile sdk`; the Python wheel packages that CLI and its closed dependency tree.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ stdio JSON-RPC 对外服务接口(`@deepseek-ai/dsh-sdk-jsonrpc-server`,见[
|
||||
- **`@deepseek-ai/dsh-subagent-dsh-sdk`**(`packages/subagent/subagent-dsh-sdk/`)—— 第二个进程外 `SubagentProvider`,采用与 `subagent-acp` 对等的结构:同样的全 false 能力与 `inheritsParentContext: false`,同样的握手后发布所有权事务,同样通过 `onError` sink 将结果归一为绝不拒绝,同样的父命名空间 run id。子答案从流式 `session.event` 读取——最后一条完整 `assistant/message`,否则累积的 `text-delta` 块,部分答案在取消时得以保留。停止原因由子进程的结构化 `TurnEndReason` 映射(`completed`/`max-tokens`/`aborted` 直通;其余一切、包括未运行任何轮次便已结束的子进程,都是 `error`)。其 `dshBin`/profile/patch/home 配置选择隔离的 SDK 应用,`provider`/`model` 写入子进程 `initialize`,`env` 则提供子进程专用的显式值,例如其 API key。
|
||||
- **subagent seam 新增 `out-of-process.ts`**:两个进程外后端共享的 provider 侧词汇——`NO_START_CAPABILITIES`、时限校验、子进程 cwd 解析(配置覆盖、否则发起委托的父会话工作区)、绝不拒绝的 `settleRunResult`、以及 `subprocessRunHandle` 发布。进程机制(spawn、环境清理、进程树清理)属于 `dsh-subprocess` seam;`subagent-acp` 经 `ctx.subprocess` spawn 子进程,本后端则经 SDK 客户端 spawn 子进程(subprocess README 记载的 SDK 托管传输例外)并自行应用该 seam 的 `scrubbedParentEnv()`。
|
||||
|
||||
`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致);私有 `@deepseek-ai/dsh-sdk-python-runtime` 载体通过其打包闭包消费共享协议。
|
||||
`dsh-sdk-jsonrpc-server` 的服务不变(协议字节完全一致)。TypeScript 与 Python 客户端都通过 `dsh --profile sdk` 消费共享协议;Python wheel 会打包该 CLI 及其封闭依赖树。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ DeepSeek Harness is an all-plugin agent harness on vendored Cordis. Read [docs/a
|
||||
|
||||
## Application launch
|
||||
|
||||
Node apps launch only through `dsh` profiles; application-package bins, demos, and SDK argv escape hatches are forbidden. The private Python runtime is the sole temporary exception. [Architecture](docs/architecture.md#application-launch) owns scope and deferred artifact rename; `pnpm run verify-application-entrypoints` enforces it.
|
||||
Supported Node applications launch only through `dsh` profiles; application-package bins, demos, and public SDK argv escape hatches are forbidden. [Architecture](docs/architecture.md#application-launch) owns the launch set; `pnpm run verify-application-entrypoints` enforces it.
|
||||
|
||||
## Repository layout
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ External packages that a workspace package resolves at runtime. The tier covers
|
||||
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
|
||||
| [`fflate`](https://github.com/101arrowz/fflate) | MIT |
|
||||
| [`immer`](https://github.com/immerjs/immer) | MIT |
|
||||
| [`import-meta-resolve`](https://github.com/wooorm/import-meta-resolve) | MIT |
|
||||
| [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT |
|
||||
| [`katex`](https://github.com/KaTeX/KaTeX) | MIT |
|
||||
| [`koffi`](https://github.com/Koromix/koffi) | MIT |
|
||||
|
||||
+1
-1
@@ -41,7 +41,7 @@ switch (invocation.mode) {
|
||||
}
|
||||
case 'dump-config': {
|
||||
const { runDumpConfig } = await import('./dump-config.ts')
|
||||
runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
|
||||
await runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches)
|
||||
break
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -26,9 +26,10 @@ const NAME = 'dsh'
|
||||
* (the recovery diagnostic for a broken `cordis.patch.yml`, which is then
|
||||
* never parsed).
|
||||
* @param patches - `--patch` overlay paths, in argv order.
|
||||
* @returns settlement after the profile is healed and the dump is written.
|
||||
*/
|
||||
export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void {
|
||||
const loaded = prepareProfile(profile, !defaultOnly)
|
||||
export async function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): Promise<void> {
|
||||
const loaded = await prepareProfile(profile, !defaultOnly)
|
||||
const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({
|
||||
label: layer.packageName,
|
||||
patches: layer.patches,
|
||||
|
||||
@@ -115,8 +115,8 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b
|
||||
* @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
|
||||
* @returns the loaded profile.
|
||||
*/
|
||||
export function prepareProfile(name: string, userLayer = true): Profile {
|
||||
healProfilesModuleFallback(INSTALL_ANCHOR)
|
||||
export async function prepareProfile(name: string, userLayer = true): Promise<Profile> {
|
||||
await healProfilesModuleFallback(INSTALL_ANCHOR)
|
||||
const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
|
||||
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
|
||||
return profile
|
||||
@@ -154,11 +154,11 @@ function allPatches(composed: ComposedProfile): PatchOptions[] {
|
||||
* @param patchFiles - `--patch` overlay paths, in argv order.
|
||||
* @returns the profile and its patch layers.
|
||||
*/
|
||||
function composeProfile(
|
||||
async function composeProfile(
|
||||
name: string,
|
||||
patchFiles: readonly string[],
|
||||
): ComposedProfile {
|
||||
const profile = prepareProfile(name)
|
||||
): Promise<ComposedProfile> {
|
||||
const profile = await prepareProfile(name)
|
||||
const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? []
|
||||
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
|
||||
const bundlePatches = profile.layers.flatMap(layer => layer.patches)
|
||||
@@ -207,7 +207,7 @@ 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 }> {
|
||||
const composed = composeProfile(options.profile, options.patchFiles)
|
||||
const composed = await composeProfile(options.profile, options.patchFiles)
|
||||
const app: { current?: Context } = {}
|
||||
const appReady = createAppReady()
|
||||
const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() })
|
||||
|
||||
@@ -107,7 +107,7 @@ async function bootWeb(
|
||||
// upward walk. The flat fallback the preset boot maintains is what makes
|
||||
// them resolvable — the same mechanism, not a test-only shim.
|
||||
const home = dirname(settingsFile)
|
||||
healProfilesModuleFallback(INSTALL_ANCHOR, home)
|
||||
await healProfilesModuleFallback(INSTALL_ANCHOR, home)
|
||||
const profileDir = join(home, 'profiles', 'spec')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
// Product Bundles are installed into the Profile, not the dsh app. Model
|
||||
|
||||
@@ -519,7 +519,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
// The production module-resolution setup: an empty profile root inside the temp
|
||||
// harness home, with bare plugin names resolving through the flat module
|
||||
// fallback the launcher heals under <home>/profiles.
|
||||
healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome)
|
||||
await healProfilesModuleFallback(INSTALL_ANCHOR, harnessHome)
|
||||
const profileDir = join(harnessHome, 'profiles', 'scaffold')
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
|
||||
@@ -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: 81c53abb799a91a2f7601f7506defbe9dc465820
|
||||
README.zh.md: 0d9e0707e0e809f4b2db0643a04619573107941a
|
||||
README.md: f48abfa445e4ac604b5e1bc694f8bae395b566e7
|
||||
README.zh.md: e903c74fb12fee0fca1d201d217dd28be8b7e08d
|
||||
|
||||
@@ -35,7 +35,7 @@ This package carries no loader hooks and no dev-mode surface. The [`dsh` app](..
|
||||
|
||||
## Profiles
|
||||
|
||||
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable writes real ESM proxy packages that mirror explicit exports and re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged.
|
||||
A profile is a directory under `$DSH_HOME/profiles/<name>` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list and `patchReload: live | startup` — and the user's own `cordis.patch.yml`. `live` watches the profile and home-level patch files after boot; `startup` applies every layer once. A missing value keeps the historical `live` default for custom profiles. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory under a cross-process writer lock. Plain Node writes one symlink per package in the installation dependency closure; a pkg executable resolves exports with Node ESM import conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. The lock prevents concurrent launchers from observing partial proxies, and either carrier replaces the other carrier's managed entry. Both forms let profile plugins resolve installation packages through Node's ordinary parent walk and preserve one module instance for external plugin peers. `PROFILE_TEMPLATES` auto-initializes `web` with live reload and `headless`/`sdk`/`acp` with startup-only patches; other names fail loud until `initProfile` creates them through `dsh plugin`. `loadProfile` normalizes an exact installation-owned bundle tuple and a missing reload choice to its shipped template while preserving every explicit reload choice and every other manifest field; any extra, missing, or reordered bundle makes the list user-owned and leaves it unchanged.
|
||||
|
||||
User-level machine-local preferences also live in the Harness home:
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面
|
||||
|
||||
## Profiles
|
||||
|
||||
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则写入真实 ESM 代理包,镜像显式 exports 并重新导出虚拟模块 URL,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。
|
||||
profile 是位于 `$DSH_HOME/profiles/<name>` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.zh.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表和 `patchReload: live | startup`)和用户自己的 `cordis.patch.yml`。`live` 会在启动后监视 profile 与 home 级 patch 文件;`startup` 只应用每层一次。缺失值为自定义 profile 保留历史 `live` 默认值。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 在跨进程写入锁下维护扁平的 `$DSH_HOME/profiles/node_modules` 目录。普通 Node 为安装依赖闭包中的每个包写入一个符号链接;pkg 可执行程序则按 Node ESM import 条件解析 exports,并写入重新导出虚拟模块 URL 的真实代理包,因为操作系统符号链接无法进入 pkg 的 `/snapshot` 树。该锁防止并发启动器观察到未完整写入的代理,而两种载体都会替换另一种载体留下的受管条目。两种形式都使 profile 插件可以通过 Node 常规的逐级向上查找解析安装包,并让外部插件 peer 共用一个模块实例。`PROFILE_TEMPLATES` 首次使用时以实时重载初始化 `web`,以仅启动时 patch 初始化 `headless`/`sdk`/`acp`;其他名称在通过 `dsh plugin` 由 `initProfile` 创建前都会明确报错。`loadProfile` 会把安装自有的精确组合包元组和缺失的重载选择规范化为随附模板,同时保留每个显式重载选择和 manifest 中其他所有字段;组合包一旦有任何额外、缺失或重排,列表就归用户所有并保持不变。
|
||||
|
||||
用户级的机器本地偏好同样位于 harness home 中:
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-atomic-write": "workspace:^",
|
||||
"import-meta-resolve": "^4.2.0",
|
||||
"js-yaml": "^4.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -27,10 +27,12 @@ import {
|
||||
existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, unlinkSync, writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
||||
import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
||||
import { resolve as resolveImport } from 'import-meta-resolve'
|
||||
import { loadOverlayPatches } from './index.ts'
|
||||
|
||||
/** Directory under the Harness home holding every profile. */
|
||||
@@ -205,7 +207,16 @@ export function initProfile(
|
||||
if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE)
|
||||
}
|
||||
|
||||
/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */
|
||||
function readModuleProxyRecord(link: string): ModuleProxyRecord | undefined {
|
||||
try {
|
||||
return JSON.parse(readFileSync(join(link, 'package.json'), 'utf8')) as ModuleProxyRecord
|
||||
} catch {
|
||||
// Missing or invalid metadata is not managed state; callers reject it.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensure `link` is a symlink to `target`, replacing a wrong link or a dsh-managed packaged proxy. */
|
||||
function ensureSymlink(link: string, target: string): void {
|
||||
let stat
|
||||
try {
|
||||
@@ -217,12 +228,19 @@ function ensureSymlink(link: string, target: string): void {
|
||||
}
|
||||
if (stat !== undefined) {
|
||||
if (!stat.isSymbolicLink()) {
|
||||
throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`)
|
||||
const existing = stat.isDirectory() ? readModuleProxyRecord(link) : undefined
|
||||
if (existing?.dsh?.moduleFallback?.targets === undefined) {
|
||||
throw new Error(`dsh: ${link} exists and is not a symlink or dsh-managed module proxy; remove it so dsh can manage the installation fallback`)
|
||||
}
|
||||
rmSync(link, { recursive: true })
|
||||
stat = undefined
|
||||
}
|
||||
if (stat !== undefined) {
|
||||
if (readlinkSync(link) === target) return
|
||||
// unlink deletes the reparse point itself on Windows too; rmSync treats a
|
||||
// junction as a directory and throws EISDIR unless recursive.
|
||||
unlinkSync(link)
|
||||
}
|
||||
if (readlinkSync(link) === target) return
|
||||
// unlink deletes the reparse point itself on Windows too; rmSync treats a
|
||||
// junction as a directory and throws EISDIR unless recursive.
|
||||
unlinkSync(link)
|
||||
}
|
||||
try {
|
||||
symlinkSync(target, link, 'junction')
|
||||
@@ -258,29 +276,40 @@ function isPackagedExecutable(): boolean {
|
||||
return (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined
|
||||
}
|
||||
|
||||
/** Resolve one package specifier from the dsh installation. */
|
||||
function packageEntryFromAnchor(anchor: string, specifier: string): string | undefined {
|
||||
/** Resolve one package export with the ESM conditions used by the generated proxy. */
|
||||
function packageEntryFromPackage(packageName: string, packageDir: string, specifier: string): string {
|
||||
try {
|
||||
return createRequire(anchor).resolve(specifier)
|
||||
} catch {
|
||||
return undefined
|
||||
const resolved = resolveImport(specifier, pathToFileURL(join(packageDir, 'package.json')).href)
|
||||
if (!resolved.startsWith('file:') || !existsSync(fileURLToPath(resolved))) {
|
||||
throw new Error(`resolved to missing or non-file URL ${resolved}`)
|
||||
}
|
||||
return resolved
|
||||
} catch (error) {
|
||||
throw new Error(`dsh: cannot resolve ESM export ${specifier} from installed package ${packageName}`, { cause: error })
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve every explicit runtime export that an out-of-tree plugin can import. */
|
||||
function packageProxySource(
|
||||
installAnchor: string,
|
||||
packageName: string,
|
||||
packageDir: string,
|
||||
): { version: string; targets: Record<string, string> } {
|
||||
const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as {
|
||||
exports?: unknown
|
||||
main?: unknown
|
||||
version?: unknown
|
||||
}
|
||||
if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
|
||||
throw new Error(`dsh: installed package ${packageName} must declare a non-empty version`)
|
||||
}
|
||||
const declared = manifest.exports
|
||||
if (declared === undefined) {
|
||||
const entry = join(packageDir, typeof manifest.main === 'string' ? manifest.main : 'index.js')
|
||||
if (!existsSync(entry)) {
|
||||
throw new Error(`dsh: installed package ${packageName} main entry is missing at ${entry}`)
|
||||
}
|
||||
return { version: manifest.version, targets: { '.': pathToFileURL(entry).href } }
|
||||
}
|
||||
const subpaths = declared !== null && typeof declared === 'object' && !Array.isArray(declared)
|
||||
&& Object.keys(declared).some(key => key.startsWith('.'))
|
||||
? Object.keys(declared).filter(key => key === '.' || (key.startsWith('./') && !key.includes('*') && key !== './package.json'))
|
||||
@@ -288,8 +317,7 @@ function packageProxySource(
|
||||
const targets: Record<string, string> = {}
|
||||
for (const subpath of subpaths) {
|
||||
const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1)
|
||||
const entry = packageEntryFromAnchor(installAnchor, specifier)
|
||||
if (entry !== undefined) targets[subpath] = pathToFileURL(entry).href
|
||||
targets[subpath] = packageEntryFromPackage(packageName, packageDir, specifier)
|
||||
}
|
||||
return { version: manifest.version, targets }
|
||||
}
|
||||
@@ -328,18 +356,13 @@ function ensureModuleProxy(
|
||||
stat = undefined
|
||||
}
|
||||
if (stat !== undefined) {
|
||||
const marker = join(link, 'package.json')
|
||||
let existing: ModuleProxyRecord | undefined
|
||||
try {
|
||||
existing = JSON.parse(readFileSync(marker, 'utf8')) as ModuleProxyRecord
|
||||
} catch {
|
||||
existing = undefined
|
||||
}
|
||||
const existing = readModuleProxyRecord(link)
|
||||
if (existing?.dsh?.moduleFallback?.targets === undefined) {
|
||||
throw new Error(`dsh: ${link} exists and is not a dsh-managed module proxy; remove it so dsh can manage the installation fallback`)
|
||||
}
|
||||
if (existing.version === version
|
||||
&& JSON.stringify(existing.dsh.moduleFallback.targets) === JSON.stringify(targets)) return
|
||||
&& JSON.stringify(existing.dsh.moduleFallback.targets) === JSON.stringify(targets)
|
||||
&& Object.keys(targets).every((_, index) => existsSync(join(link, `entry-${index}.js`)))) return
|
||||
rmSync(link, { recursive: true })
|
||||
}
|
||||
mkdirSync(link, { recursive: true })
|
||||
@@ -357,10 +380,12 @@ function ensureModuleProxy(
|
||||
* Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one
|
||||
* entry per package in the dsh app's resolvable dependency CLOSURE (BFS
|
||||
* over `dependencies` from the app manifest), each resolved from its own
|
||||
* installation location. Plain Node uses symlinks. A pkg executable writes
|
||||
* small ESM proxy packages instead because the host filesystem cannot follow
|
||||
* a symlink into pkg's virtual `/snapshot` tree; the proxy re-exports the
|
||||
* virtual URL, preserving the executable's single module instance. Node's
|
||||
* installation location. Plain Node uses symlinks. A pkg executable resolves
|
||||
* exports under ESM import conditions and writes small proxy packages because
|
||||
* the host filesystem cannot follow a symlink into pkg's virtual `/snapshot`
|
||||
* tree; the proxy re-exports the virtual URL, preserving the executable's
|
||||
* single module instance. One cross-process writer lock prevents partial
|
||||
* proxies and serializes carrier transitions. Node's
|
||||
* parent-directory walk from any profile finds this
|
||||
* directory after the profile's own `node_modules`, so every in-box plugin
|
||||
* resolves without pnpm ever managing it — the exact "bundles come from the
|
||||
@@ -375,11 +400,20 @@ function ensureModuleProxy(
|
||||
* reused because resolution cannot discover it.
|
||||
* @param installAnchor - absolute path of the dsh app's package.json.
|
||||
* @param home - the Harness home; defaults to {@link resolveDshHome}.
|
||||
* @returns settlement after the locked fallback generation is complete.
|
||||
*/
|
||||
export function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): void {
|
||||
export async function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): Promise<void> {
|
||||
const profilesDir = join(home, PROFILES_DIR)
|
||||
const modulesDir = join(profilesDir, 'node_modules')
|
||||
mkdirSync(modulesDir, { recursive: true })
|
||||
await withFileLock(modulesDir, () => {
|
||||
healProfilesModuleFallbackLocked(installAnchor, modulesDir)
|
||||
return Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
/** Heal one module-fallback generation while the cross-process writer lock is held. */
|
||||
function healProfilesModuleFallbackLocked(installAnchor: string, modulesDir: string): void {
|
||||
const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest
|
||||
const links = new Map<string, string>()
|
||||
/* v8 ignore next -- a real app manifest always declares its name */
|
||||
@@ -407,7 +441,7 @@ export function healProfilesModuleFallback(installAnchor: string, home: string =
|
||||
const link = join(modulesDir, packageName)
|
||||
mkdirSync(dirname(link), { recursive: true })
|
||||
if (isPackagedExecutable()) {
|
||||
const source = packageProxySource(installAnchor, packageName, target)
|
||||
const source = packageProxySource(packageName, target)
|
||||
if (Object.keys(source.targets).length > 0) {
|
||||
ensureModuleProxy(link, packageName, source.version, source.targets)
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
* empty-root composition, and the installation module-fallback healing.
|
||||
*/
|
||||
|
||||
import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { withFileLock } from '@deepseek-ai/dsh-atomic-write'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
composeEntries,
|
||||
@@ -271,7 +272,7 @@ describe('composeEntries', () => {
|
||||
})
|
||||
|
||||
describe('healProfilesModuleFallback', () => {
|
||||
it('links the app and bundle dependency surface flat under profiles/node_modules', () => {
|
||||
it('links the app and bundle dependency surface flat under profiles/node_modules', async () => {
|
||||
const anchor = stageInstallation({
|
||||
'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } },
|
||||
'plain-lib': {},
|
||||
@@ -285,7 +286,7 @@ describe('healProfilesModuleFallback', () => {
|
||||
mkdirSync(join(modules, 'dep-of-a'), { recursive: true })
|
||||
writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' }))
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
// App deps, the bundle's own deps, and the bundle itself are linked; the
|
||||
// plain library is linked as an app dep (harmless), the app itself too.
|
||||
@@ -293,42 +294,67 @@ describe('healProfilesModuleFallback', () => {
|
||||
expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true)
|
||||
}
|
||||
// Idempotent, and a moved target is re-pointed.
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
const before = readlinkSync(join(fallback, 'dep-of-a'))
|
||||
expect(before).toContain('dep-of-a')
|
||||
})
|
||||
|
||||
it('throws when a fallback entry is a real directory', () => {
|
||||
it('throws when a fallback entry is a foreign file or directory', async () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true })
|
||||
expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink')
|
||||
for (const kind of ['file', 'directory']) {
|
||||
const home = tmp()
|
||||
const entry = join(home, 'profiles', 'node_modules', 'dsh-app')
|
||||
mkdirSync(join(entry, '..'), { recursive: true })
|
||||
if (kind === 'directory') mkdirSync(entry)
|
||||
else writeFileSync(entry, '')
|
||||
await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow('is not a symlink')
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces a wrong symlink', () => {
|
||||
it('replaces a wrong symlink', async () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
mkdirSync(fallback, { recursive: true })
|
||||
symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction')
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app')
|
||||
})
|
||||
|
||||
it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => {
|
||||
// The EEXIST arm: a second process wrote the link between our lstat miss
|
||||
// and symlinkSync. Simulated by pre-creating the correct link and calling
|
||||
// the internal path through a stale-lstat shim is not possible from
|
||||
// outside, so probe the observable contract: healing twice concurrently
|
||||
// is a no-op, and a foreign REAL directory still fails loud.
|
||||
it('serializes concurrent healers and retains the identical link', async () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
healProfilesModuleFallback(anchor, home) // second healer sees the correct link
|
||||
await Promise.all([
|
||||
healProfilesModuleFallback(anchor, home),
|
||||
healProfilesModuleFallback(anchor, home),
|
||||
])
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true)
|
||||
})
|
||||
|
||||
it('waits for the module-fallback writer lock before publishing entries', async () => {
|
||||
const anchor = stageInstallation({})
|
||||
const home = tmp()
|
||||
const modules = join(home, 'profiles', 'node_modules')
|
||||
mkdirSync(modules, { recursive: true })
|
||||
let releaseLock: (() => void) | undefined
|
||||
let reportLock: (() => void) | undefined
|
||||
const lockHeld = new Promise<void>((resolve) => { reportLock = resolve })
|
||||
const release = new Promise<void>((resolve) => { releaseLock = resolve })
|
||||
const holder = withFileLock(modules, async () => {
|
||||
reportLock?.()
|
||||
await release
|
||||
})
|
||||
await lockHeld
|
||||
|
||||
const healer = healProfilesModuleFallback(anchor, home)
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
expect(existsSync(join(modules, 'dsh-app'))).toBe(false)
|
||||
releaseLock?.()
|
||||
await Promise.all([holder, healer])
|
||||
expect(lstatSync(join(modules, 'dsh-app')).isSymbolicLink()).toBe(true)
|
||||
})
|
||||
|
||||
it('writes real ESM proxies for a packaged executable', async () => {
|
||||
const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
|
||||
const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
|
||||
@@ -339,7 +365,7 @@ describe('healProfilesModuleFallback', () => {
|
||||
const home = tmp()
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
try {
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
const proxy = join(fallback, 'bundle-a')
|
||||
expect(lstatSync(proxy).isDirectory()).toBe(true)
|
||||
@@ -355,13 +381,79 @@ describe('healProfilesModuleFallback', () => {
|
||||
expect(proxyManifest.dsh.moduleFallback.targets['.']).toEqual(expect.stringContaining('/bundle-a/index.js'))
|
||||
await expect(import(join(proxy, 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' })
|
||||
await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ feature: 'proxied' })
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
} finally {
|
||||
delete (process as NodeJS.Process & { pkg?: unknown }).pkg
|
||||
}
|
||||
})
|
||||
|
||||
it('requires a package version before writing a packaged proxy', () => {
|
||||
it('resolves import-only exports from each package installation', async () => {
|
||||
const anchor = stageInstallation({
|
||||
'bundle-a': { patch: '[]\n', deps: { 'nested-esm': '0.0.0' } },
|
||||
})
|
||||
const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
|
||||
const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
bundleManifest.exports = { '.': { import: './index.js' } }
|
||||
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest))
|
||||
const nestedDir = join(bundleDir, 'node_modules', 'nested-esm')
|
||||
mkdirSync(nestedDir, { recursive: true })
|
||||
writeFileSync(join(nestedDir, 'package.json'), JSON.stringify({
|
||||
name: 'nested-esm',
|
||||
version: '0.0.0',
|
||||
type: 'module',
|
||||
exports: { import: './index.js' },
|
||||
}))
|
||||
writeFileSync(join(nestedDir, 'index.js'), 'export const nested = "proxied"\n')
|
||||
const home = tmp()
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
try {
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
const fallback = join(home, 'profiles', 'node_modules')
|
||||
await expect(import(join(fallback, 'bundle-a', 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' })
|
||||
await expect(import(join(fallback, 'nested-esm', 'entry-0.js'))).resolves.toMatchObject({ nested: 'proxied' })
|
||||
} finally {
|
||||
delete (process as NodeJS.Process & { pkg?: unknown }).pkg
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the legacy index fallback when a package has no exports or main', async () => {
|
||||
const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
|
||||
const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
|
||||
const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
delete manifest.main
|
||||
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
try {
|
||||
const home = tmp()
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js')))
|
||||
.resolves.toMatchObject({ packageName: 'bundle-a' })
|
||||
} finally {
|
||||
delete (process as NodeJS.Process & { pkg?: unknown }).pkg
|
||||
}
|
||||
})
|
||||
|
||||
it('fails loud on a missing legacy main entry or ESM export target', async () => {
|
||||
for (const mode of ['legacy', 'exports']) {
|
||||
const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
|
||||
const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
|
||||
const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
if (mode === 'legacy') delete manifest.main
|
||||
else manifest.exports = { '.': { import: './missing.js' } }
|
||||
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
|
||||
rmSync(join(bundleDir, 'index.js'))
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
try {
|
||||
await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow(
|
||||
mode === 'legacy' ? 'main entry is missing' : 'cannot resolve ESM export bundle-a',
|
||||
)
|
||||
} finally {
|
||||
delete (process as NodeJS.Process & { pkg?: unknown }).pkg
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('requires a package version before writing a packaged proxy', async () => {
|
||||
const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
|
||||
const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a')
|
||||
const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
@@ -369,7 +461,7 @@ describe('healProfilesModuleFallback', () => {
|
||||
writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest))
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
try {
|
||||
expect(() => { healProfilesModuleFallback(anchor, tmp()) }).toThrow(
|
||||
await expect(healProfilesModuleFallback(anchor, tmp())).rejects.toThrow(
|
||||
'installed package bundle-a must declare a non-empty version',
|
||||
)
|
||||
} finally {
|
||||
@@ -377,23 +469,23 @@ describe('healProfilesModuleFallback', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('replaces plain-node links and stale managed proxies in packaged mode', () => {
|
||||
it('replaces plain-node links and stale managed proxies in packaged mode', async () => {
|
||||
const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
|
||||
const home = tmp()
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
const proxy = join(home, 'profiles', 'node_modules', 'bundle-a')
|
||||
expect(lstatSync(proxy).isSymbolicLink()).toBe(true)
|
||||
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
try {
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
expect(lstatSync(proxy).isDirectory()).toBe(true)
|
||||
const stale = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as {
|
||||
version: string
|
||||
}
|
||||
stale.version = 'stale'
|
||||
writeFileSync(join(proxy, 'package.json'), JSON.stringify(stale))
|
||||
healProfilesModuleFallback(anchor, home)
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
expect(JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8'))).toMatchObject({
|
||||
version: '0.0.0',
|
||||
})
|
||||
@@ -402,7 +494,23 @@ describe('healProfilesModuleFallback', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects foreign packaged fallback directories with valid or invalid metadata', () => {
|
||||
it('replaces a managed packaged proxy with a plain-node symlink', async () => {
|
||||
const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
|
||||
const home = tmp()
|
||||
const fallback = join(home, 'profiles', 'node_modules', 'bundle-a')
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
try {
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
expect(lstatSync(fallback).isDirectory()).toBe(true)
|
||||
} finally {
|
||||
delete (process as NodeJS.Process & { pkg?: unknown }).pkg
|
||||
}
|
||||
|
||||
await healProfilesModuleFallback(anchor, home)
|
||||
expect(lstatSync(fallback).isSymbolicLink()).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects foreign packaged fallback directories with valid or invalid metadata', async () => {
|
||||
const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } })
|
||||
Object.defineProperty(process, 'pkg', { configurable: true, value: {} })
|
||||
try {
|
||||
@@ -411,7 +519,7 @@ describe('healProfilesModuleFallback', () => {
|
||||
const proxy = join(home, 'profiles', 'node_modules', 'bundle-a')
|
||||
mkdirSync(proxy, { recursive: true })
|
||||
writeFileSync(join(proxy, 'package.json'), metadata)
|
||||
expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow(
|
||||
await expect(healProfilesModuleFallback(anchor, home)).rejects.toThrow(
|
||||
'exists and is not a dsh-managed module proxy',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../util/atomic-write"
|
||||
},
|
||||
{
|
||||
"path": "../../util/launch-environment"
|
||||
},
|
||||
|
||||
Generated
+6
@@ -1145,6 +1145,12 @@ importers:
|
||||
|
||||
packages/boot/app-boot:
|
||||
dependencies:
|
||||
'@deepseek-ai/dsh-atomic-write':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/atomic-write
|
||||
import-meta-resolve:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
js-yaml:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
|
||||
@@ -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/README.md
|
||||
README.md: edf5ced63f181bb17895d81cc060577916554305
|
||||
README.zh.md: e03a19e5b8f0cef2e2f9d86f8d91b8808e268ff4
|
||||
README.md: ef2ba1764aa93fc6499941dd372a1de94f33d454
|
||||
README.zh.md: b5c36b6ff0fbce303649ae92efd7e1edac954e82
|
||||
|
||||
@@ -26,7 +26,7 @@ with DeepSeekHarness(
|
||||
print(result.final_response)
|
||||
```
|
||||
|
||||
`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment.
|
||||
`DeepSeekHarness` starts lazily and reuses its runtime until `close()` or context-manager exit. The initial profile handshake has an independent 10-second default bound through `initialize_timeout_seconds`; ordinary turns remain unbounded unless `request_timeout_seconds` is set. A timeout names the selected profile and includes retained runtime diagnostics. `cwd` is the agent workspace; `runtime_cwd` independently selects the subprocess working directory. Both become absolute before launch. `provider`, `model`, and optional positive `max_tokens` are sent during JSON-RPC initialization. `base_url` and `api_key` explicitly override `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY` in the child environment.
|
||||
|
||||
## Customize plugins
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ with DeepSeekHarness(
|
||||
print(result.final_response)
|
||||
```
|
||||
|
||||
`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。
|
||||
`DeepSeekHarness` 延迟启动运行时,并在调用 `close()` 或退出上下文管理器前复用该进程。首次 profile 握手通过 `initialize_timeout_seconds` 使用独立的 10 秒默认上限;普通轮次在未设置 `request_timeout_seconds` 时仍不设上限。超时诊断会指明所选 profile,并包含保留的运行时诊断。`cwd` 是 agent workspace;`runtime_cwd` 独立选择子进程工作目录。两者都会在启动前转成绝对路径。`provider`、`model` 和可选的正整数 `max_tokens` 通过 JSON-RPC 初始化发送。`base_url` 与 `api_key` 会显式覆盖子进程环境中的 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`。
|
||||
|
||||
## 自定义插件
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class DeepSeekHarnessConfig:
|
||||
patches: tuple[str, ...] = ()
|
||||
dsh_home: str | None = None
|
||||
env: dict[str, str] = field(default_factory=dict)
|
||||
initialize_timeout_seconds: float = 10.0
|
||||
request_timeout_seconds: float | None = None
|
||||
shutdown_timeout_seconds: float | None = 1.0
|
||||
base_url: str | None = None
|
||||
@@ -79,6 +80,7 @@ class DeepSeekHarness:
|
||||
dsh_home=self.config.dsh_home,
|
||||
cwd=runtime_cwd,
|
||||
env=env,
|
||||
initialize_timeout_seconds=self.config.initialize_timeout_seconds,
|
||||
request_timeout_seconds=self.config.request_timeout_seconds,
|
||||
shutdown_timeout_seconds=self.config.shutdown_timeout_seconds,
|
||||
),
|
||||
|
||||
@@ -31,6 +31,7 @@ class HarnessConfig:
|
||||
dsh_home: str | None = None
|
||||
cwd: str | None = None
|
||||
env: dict[str, str] | None = None
|
||||
initialize_timeout_seconds: float = 10.0
|
||||
request_timeout_seconds: float | None = None
|
||||
shutdown_timeout_seconds: float | None = 1.0
|
||||
_launch_args: tuple[str, ...] | None = None
|
||||
@@ -137,7 +138,15 @@ class HarnessClient:
|
||||
if max_tokens is not None:
|
||||
payload["maxTokens"] = max_tokens
|
||||
try:
|
||||
return self.request("initialize", payload, response_model=InitializeResponse)
|
||||
return self.request(
|
||||
"initialize",
|
||||
payload,
|
||||
response_model=InitializeResponse,
|
||||
timeout_seconds=self.config.initialize_timeout_seconds,
|
||||
)
|
||||
except TimeoutError as error:
|
||||
self.close()
|
||||
raise TimeoutError(f"{error}\nselected dsh profile {self.config.profile!r}") from error
|
||||
except BaseException as error:
|
||||
self.close()
|
||||
diagnostics = self._runtime_diagnostics()
|
||||
|
||||
@@ -731,7 +731,8 @@ time.sleep(60)
|
||||
with HarnessClient(
|
||||
HarnessConfig(
|
||||
_launch_args=(sys.executable, str(script)),
|
||||
request_timeout_seconds=0.1,
|
||||
profile="web",
|
||||
initialize_timeout_seconds=0.1,
|
||||
)
|
||||
) as client:
|
||||
start = time.monotonic()
|
||||
@@ -740,6 +741,7 @@ time.sleep(60)
|
||||
except TimeoutError as exc:
|
||||
assert time.monotonic() - start < 2
|
||||
assert "bridge is still starting" in str(exc)
|
||||
assert "profile 'web'" in str(exc)
|
||||
else:
|
||||
raise AssertionError("initialize should time out")
|
||||
|
||||
@@ -832,6 +834,8 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None:
|
||||
assert {"dsh_bin", "profile", "patches", "dsh_home"} <= set(
|
||||
HarnessConfig.__dataclass_fields__
|
||||
)
|
||||
assert "initialize_timeout_seconds" in DeepSeekHarnessConfig.__dataclass_fields__
|
||||
assert "initialize_timeout_seconds" in HarnessConfig.__dataclass_fields__
|
||||
for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"):
|
||||
assert removed not in DeepSeekHarnessConfig.__dataclass_fields__
|
||||
assert removed not in HarnessConfig.__dataclass_fields__
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const script = resolve(root, 'scripts/build-exe-for-python-sdk.ts')
|
||||
|
||||
describe('Python runtime executable assets', () => {
|
||||
it('packages the dynamically resolved web frontend distribution', () => {
|
||||
const result = spawnSync(process.execPath, [
|
||||
'--import',
|
||||
'tsx/esm',
|
||||
script,
|
||||
'--skip-build',
|
||||
'--dry-run',
|
||||
'--targets=node24-macos-arm64',
|
||||
], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, npm_execpath: 'C:\\tools\\pnpm.cjs' },
|
||||
})
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*')
|
||||
expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*')
|
||||
})
|
||||
})
|
||||
@@ -47,6 +47,7 @@ const ASSET_GLOBS = [
|
||||
'node_modules/**/*.mjs',
|
||||
'node_modules/**/package.json',
|
||||
'node_modules/**/*.json',
|
||||
// Package-owned Markdown includes runtime skill instructions and badge content.
|
||||
'node_modules/**/*.md',
|
||||
'node_modules/**/*.dylib',
|
||||
'node_modules/**/*.dll',
|
||||
@@ -56,6 +57,10 @@ const ASSET_GLOBS = [
|
||||
'node_modules/**/*.wasm',
|
||||
'node_modules/**/*.yaml',
|
||||
'node_modules/**/*.yml',
|
||||
// web-app builds this path dynamically, so pkg cannot discover the static frontend.
|
||||
'node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*',
|
||||
// skill-badge resolves both Markdown and image resources through import.meta.url.
|
||||
'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*',
|
||||
]
|
||||
|
||||
const PLATFORMS = ['linux', 'macos'] as const
|
||||
|
||||
Reference in New Issue
Block a user