diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 5b400d15a2..aaa8fdbedc 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -18,14 +18,14 @@ en: 5fdde26e9e8c6108 zh: 629559d733c1f0c5 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/plugin-resolution-the-vfs-holds-a-real-package-tree-the-closure-manifest-is-the-deploy-root: - en: c35ea4b858448b49 - zh: 1f8ff0a9f82ca881 + en: 985e0eb63f3d1fd6 + zh: 337cc44fadc6227e /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/build-pipeline-and-artifacts: - en: d3bbd24ead02af4f - zh: 64623d88bee4314b + en: 921085cb9d894688 + zh: 12d99fe153d36738 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/python-sdk-distribution-two-carriers-exe-for-production-node-for-development: - en: 9bd60549d4a52ec0 - zh: e2343b7210212ee0 + en: 02e6b36f9f92c83a + zh: edd8b2db7a787483 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/decision/naming-lineage: en: 31a5aecb8bf828bb zh: 2dffb6fd2e0f8194 @@ -33,8 +33,8 @@ en: 0426f598f3f44e64 zh: fc66728e17f3bcfb /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/testing: - en: b3c9820f4dc7c6c8 - zh: 3b5768ef1770bfcb + en: 8e7f46bbb595afa9 + zh: 4641894ed285d717 /agent-note-single-file-executable-sdk-runtime-distribution-single-exe/alternatives-considered: en: 50d965ca81a401ba zh: 0c6296245b97b9ac diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 27d37ed541..60228735f5 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -44,19 +44,19 @@ The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-ru 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. The optional resource service provides MCP Resources; MCP Prompts remain unsupported. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. -The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in `-office/node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. Missing required packages and missing installed package manifests fail the copy; absent optionals preserve the kit's engine selection. +The [Office kit](2026-09-14-independent-libreoffice-kit.md) and its installed dependency tree live in the explicitly downloaded sidecar’s `node_modules`, preserving complete package contents and helper permissions. Top-level `pkg.ignore` excludes kit packages from the SEA archive, including assets selected by dependency `files` fields; small dependencies shared with other consumers remain available in the VFS. The private Python bootstrap uses Node `registerHooks` to resolve the kit entry and its package manifest from the real directory. Native executables and URL Workers therefore retain their ordinary paths without modifying the kit or the public CLI. The build locks the selected engine and dependency locations to exact npm tarballs; downloads verify their integrity before publication. ### 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-python-runtime-closure deploy --legacy --prod --config.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its target-specific Office directory into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; 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. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. +[`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.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`, including [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the carrier-root `runtime-bootstrap.mjs` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → verify the deployed bootstrap and inject pkg configuration with that bin plus assets covering dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it with its lightweight resource manifest and Office skill assets into the runtime directory. The Python runtime owns that bootstrap. It calls the public CLI export for ordinary launches and dispatches a provider-private selection to the same `@deepseek-ai/dsh-subprocess-local/runner` core without changing CLI grammar or adding another executable; the [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private path. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. The deploy flags preserve the measured runtime layout: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted supplies package directories that staging makes symlink-free; staging removes nested workspace copies only when pnpm records the same dependency identity at the payload root, preserving singleton services; disabling workspace hoisting excludes unrelated workspace source packages; 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. Production deployment permits unused workspace patches because development-only tools can be absent from its closure; included-package patch failures still fail deployment. Workspace installs retain strict unused-patch validation. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) runs [installed-wheel validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) on Linux/Windows x64 for pull requests and Linux ARM64 plus both macOS architectures for master pushes. The [public publication workflow](../../archived/process/2026-08-11-python-publication-workflow.md) calls it for all five targets; `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / macos-x64 (`macos-15-intel`) / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, verifies their package and executable origin, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests and master pushes additionally run a real DeepSeek two-turn tool smoke on their selected targets; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS checks the runtime, ripgrep, and PTY helper architectures and verifies that all three deployment targets fit the wheel tag. A full five-target run retains six artifacts, each containing one release file: the platform-independent SDK wheel and five native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and five native runtime wheels, then a single serialized job checks and publishes all six to the project PyPI registry. The [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar, `-office/` directory, 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`. +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, lightweight download metadata and Office skills, 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/runtime-bootstrap.mjs` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, its Office directory, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, lightweight resource metadata and skills, and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, `py3-none-macosx_14_0_arm64`, `py3-none-macosx_14_0_x86_64`, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. Both macOS tags deliberately declare a conservative 14.0 installation floor: the packaged Node 24 executables declare macOS 13.5, and the x64 PTY helper declares 10.7, but release validation proves the complete payload only against the 14.0 wheel claim rather than promising each observed component minimum as a supported host. The two macOS wheels remain architecture-specific; no universal2 wheel is published. The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. @@ -74,7 +74,7 @@ The verification surface has three tiers. Mechanism tier: the measured conclusio Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. -The installed-wheel `sdk-office` scenario copies the target's complete payload to a private temporary directory, requires exactly one target backend (the declared native target, or WASM when no native target is declared), and converts DOCX once through the shipped SDK profile with that engine. It checks that the kit module resolves inside the relocated directory, that conversion reports the expected backend, and that the output contains PDF bytes. Wheel validation checks the declared engine assets and native helper execute bit; CI uploads the completed wheel, so nested helper permissions stay inside the ZIP archive. +The installed-wheel `sdk-office` scenario relocates the lightweight wheel payload and explicitly downloads the complete npm Office sidecar with its independent Node. It requires exactly one target backend, verifies that the SDK loads the kit from the downloaded sidecar, and converts DOCX through the skill CLI with an empty PATH. Python Office operations use the same CLI. Explicit downloads reuse the existing build preparation implementation; SDK startup performs no downloads. The [runtime reference](../../../../python/sdk-runtime/README.md) owns cache and activation behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 4cc6e20459..272c0e80c9 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -44,19 +44,19 @@ exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server。可选资源服务提供 MCP Resources;MCP Prompts 仍不受支持。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 -[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于 `-office/node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。必需包缺失或已安装包缺少清单会使复制失败;未安装的可选包保留 kit 自身的引擎选择规则。 +[Office kit](2026-09-14-independent-libreoffice-kit.zh.md) 及其已安装依赖树位于显式下载的 sidecar 的 `node_modules`,保留完整包内容与辅助程序权限。顶层 `pkg.ignore` 从 SEA 归档排除 kit 包,包括依赖 `files` 字段选中的资源;与其他使用方共享的小型依赖仍可在 VFS 中使用。Python 私有 bootstrap 使用 Node `registerHooks` 从真实目录解析 kit 入口及其包清单。因此原生可执行程序与 URL Worker 保留普通文件路径,无需修改 kit 或公开 CLI。构建将所选引擎和依赖位置锁定到确切的 npm 归档;下载会在发布缓存前校验完整性。 ### 构建流水线与产物 -[`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.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与对应目标的 Office 目录一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`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.allow-unused-patches=true --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true --config.hoist-workspace-packages=false` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/`,其中包含作为载体根目录 `runtime-bootstrap.mjs` 的 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 验证已部署的 bootstrap,并注入以该文件为 bin 的 pkg 配置及覆盖动态读取 profile、bundle、前端、preset、原生库与配置文件的 assets → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/`,并将它与轻量资源清单及 Office skill 资源一同拷回运行时目录。该 bootstrap 由 Python runtime 拥有;普通启动时它调用公开 CLI export,而提供方私有选择会分派到同一个 `@deepseek-ai/dsh-subprocess-local/runner` 核心,不改变 CLI 语法,也不增加另一个可执行文件;[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责这条私有路径。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 提供包目录,再由暂存步骤消除符号链接;只有 pnpm 在载荷根目录记录了相同依赖标识时,暂存步骤才删除对应的嵌套工作区副本,以保留服务单例;关闭工作区提升可排除无关的工作区源码包;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`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 包验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)在拉取请求上运行 Linux/Windows x64,在 master 推送上运行 Linux ARM64 与两种 macOS 架构。[公开发布工作流](../../archived/process/2026-08-11-python-publication-workflow.md)调用它构建全部五个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64、macos-x64(`macos-15-intel`)与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求与 master 推送还会在各自选定的目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则检查 runtime、ripgrep 与 PTY helper 的架构,并验证三个载荷的部署目标都符合 wheel 包标签。完整构建五个目标时保留 6 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 5 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 5 个原生运行时 wheel 包,再由单个串行任务校验并将这 6 个文件发布到项目的 PyPI 注册表。[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、`-office/` 目录和可选的 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`。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件、轻量下载元数据与 Office skills 和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/runtime-bootstrap.mjs`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、其 Office 目录及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe、轻量资源元数据与 skills 及架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、`py3-none-macosx_14_0_arm64`、`py3-none-macosx_14_0_x86_64` 或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。两个 macOS 标签都会特意声明保守的 14.0 安装下限:打包后的 Node 24 可执行文件声明 macOS 13.5,x64 PTY helper 声明 10.7,但发布验证只按照 14.0 wheel 声明证明完整载荷,不会把各组件实测的最低版本承诺为受支持宿主。两个 macOS wheel 包仍按架构分别发布,不发布 universal2 wheel 包。 Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 @@ -74,7 +74,7 @@ Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 -安装后 wheel 的 `sdk-office` 场景会将目标的完整载荷复制到私有临时目录,要求仅存在一个目标后端(使用已声明的原生目标,未声明原生目标时使用 WASM),并通过随附 SDK profile 使用该引擎转换一次 DOCX。它检查 kit 模块确实解析到迁移后的目录、转换报告预期后端,且输出包含 PDF 字节。wheel 校验会检查声明的引擎资源与原生辅助程序执行位;CI 上传已完成的 wheel,因此嵌套辅助程序权限保留在 ZIP 归档中。 +安装后 wheel 的 `sdk-office` 场景迁移轻量 wheel 载荷,并显式下载完整 npm Office sidecar 及其独立 Node。它要求仅有一个目标后端,验证 SDK 从下载的 sidecar 加载 kit,并通过 skill CLI 在空 PATH 下转换 DOCX。Python Office 操作使用同一 CLI。显式下载复用现有构建准备实现;SDK 启动不执行下载。[运行时参考](../../../../python/sdk-runtime/README.zh.md)拥有缓存和启用行为的说明。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index 5e193c9063..ba50248d0c 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml @@ -21,8 +21,8 @@ en: 8ee88035cf64eeb3 zh: 6184e50fc4d47348 /agent-note-one-dsh-launcher-for-application-profiles/decision/python-runtime: - en: 5962abadfdece709 - zh: 5db20e96deed49d1 + en: 4b4cf5071b740c85 + zh: 595e5fe8127f9067 /agent-note-one-dsh-launcher-for-application-profiles/decision/enforcement: en: 9ce8d86b65064f38 zh: 27b8ad426cb319a2 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 7d4bb1a9c2..27a8315fe6 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -46,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher ### Python runtime -The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar, including the `web` profile. +The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. A private `DSH_RUNTIME_DOWNLOAD` selector prepares optional resources before application launch and is removed before spawning preparation subprocesses; ordinary CLI arguments do not select downloads. The [native-containment reference](../../../../packages/subprocess/subprocess-local/README.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar, including the `web` profile. The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. [docs/architecture.md](../../../../docs/architecture.md) owns this launch, and the [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows carrier. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index eb7d5ff8eb..f9a50ebf5a 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -46,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 ### Python 运行时 -Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同的 profile 语法,包括 `web` profile。 +Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。私有 `DSH_RUNTIME_DOWNLOAD` 选择器在应用启动前准备可选资源,并在启动资源准备子进程前移除;普通 CLI 参数不选择下载。[原生 containment 参考](../../../../packages/subprocess/subprocess-local/README.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同的 profile 语法,包括 `web` profile。 可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[docs/architecture.md](../../../../docs/architecture.zh.md)负责该启动方式,[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 载体。 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.i18n.yaml new file mode 100644 index 0000000000..d7f903cbe4 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md +/: + en: d6baeff3354ba733 + zh: 54e736a9718228f8 +/download-optional-python-runtime-resources-explicitly: + en: b9a46f870d69cf79 + zh: 1ed1da9167fad3b1 +/download-optional-python-runtime-resources-explicitly/change: + en: 92d45d6f2a499e9b + zh: d4560da85fc5cfbf +/download-optional-python-runtime-resources-explicitly/migration: + en: 0f0b15be52301aed + zh: 646d7b47069714d4 diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md new file mode 100644 index 0000000000..21c5a025e5 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Python runtime wheels require explicit downloads for Office conversion and bundled authoring environments." +--- + +# Download optional Python runtime resources explicitly + +English | [中文](guide.zh.md) + +## Change + +Python runtime wheels omit the Office sidecar and authoring interpreters and libraries. SDK startup does not download them. Office operations require an explicit download; the authoring environment and conversion engine can be selected independently. The SDK still packages the normal `dsh` CLI and requires no system Node.js. + +## Migration + +1. Before processing Office files, call `deepseek_harness_runtime.download_office()`. Before using the bundled authoring environment, call `deepseek_harness_runtime.download_primary_runtime()`. Both reuse completed caches and return absolute paths. +2. For offline deployment, prepare the cache with the matching runtime release on the target platform and copy it to that platform's deployment. Set `DSH_RESOURCE_CACHE` to its absolute root before downloads and SDK startup. See the [runtime reference](../../../../python/sdk-runtime/README.md). +3. Start the SDK and confirm the required resources are available. `deepseek_harness_runtime.resolve_office_launch_args()` checks the Office CLI paths without downloading. Existing `DSH_PRIMARY_RUNTIME` paths remain supported; an empty value disables the authoring query. Profile patches can independently disable `skill-office`. diff --git a/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.zh.md b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.zh.md new file mode 100644 index 0000000000..9c804c6d34 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-alpha.1/python-optional-resources/guide.zh.md @@ -0,0 +1,18 @@ +--- +kind: upgrade-guide +description: "Python 运行时 wheel 的 Office 转换与内置创作环境需要显式下载。" +--- + +# 显式下载可选 Python 运行时资源 + +[English](guide.md) | 中文 + +## 变更 + +Python 运行时 wheel 不包含 Office sidecar、创作解释器和库。SDK 启动不会下载这些资源。Office 操作需要显式下载;创作环境和转换引擎可以独立选择。SDK 仍打包常规 `dsh` CLI,无需系统 Node.js。 + +## 迁移 + +1. 处理 Office 文件前调用 `deepseek_harness_runtime.download_office()`。使用内置创作环境前调用 `deepseek_harness_runtime.download_primary_runtime()`。两者复用完整缓存并返回绝对路径。 +2. 离线部署时,在目标平台使用匹配的运行时发布版本准备缓存,并将其复制到该平台的部署环境。下载和启动 SDK 前,将 `DSH_RESOURCE_CACHE` 设为缓存的绝对根目录。详见[运行时参考](../../../../python/sdk-runtime/README.zh.md)。 +3. 启动 SDK 并确认所需资源可用。`deepseek_harness_runtime.resolve_office_launch_args()` 检查 Office CLI 路径,不执行下载。既有 `DSH_PRIMARY_RUNTIME` 路径仍受支持;空值禁用创作环境查询。Profile patch 可独立禁用 `skill-office`。 diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml index ca0a12fc89..fb39b99573 100644 --- a/packages/bundle/sdk-app/README.i18n.yaml +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -15,8 +15,8 @@ en: 4e53164159b5ce41 zh: 89582dc0294d0776 /deepseek-ai-dsh-sdk-app/use-this-package: - en: 465be8e510a86913 - zh: 07e2e1efba1d108b + en: 63573087d1db9aff + zh: 638612ac3f0da2c0 /deepseek-ai-dsh-sdk-app/model-experience: en: 215e7ba838619b7b zh: a311da3843709f90 diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md index e2615fc435..a149ec1254 100644 --- a/packages/bundle/sdk-app/README.md +++ b/packages/bundle/sdk-app/README.md @@ -33,7 +33,7 @@ The startup provider binds stdin EOF to the launcher's bounded successful shutdo The SDK uses the base `read`, `write`, and `edit` defaults. To add `str_replace_editor`, use the explicit insertion patch in the [base configuration guide](../base/README.md#use-this-package). The standalone `sdk-minimal` profile owns its separate tool selection. -The packaged Python runtime enables the [Office runtime query and skills](../../skill/tool-workspace-dependencies/README.md#use-this-package) by default. `DSH_PRIMARY_RUNTIME` overrides its bundled payload; an empty value disables both rows. Source launches without a carrier default remain opt-in. The tool reads the payload in place. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence over bundled skills. See the [runtime package](../../../python/sdk-runtime/README.md) for packaging and configuration. +The Python runtime exposes explicitly downloaded authoring resources and Office sidecars to this profile. `DSH_PRIMARY_RUNTIME` overrides the authoring payload; an empty value disables its query. Office skills can use either resource independently. Profile patches can disable `skill-office` or replace its `assetRoot` independently of `workspace-dependencies`; filesystem skills with the same name take precedence. See the [runtime package](../../../python/sdk-runtime/README.md) for downloads and configuration. ----- diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md index 610f4bef5c..f244072935 100644 --- a/packages/bundle/sdk-app/README.zh.md +++ b/packages/bundle/sdk-app/README.zh.md @@ -33,7 +33,7 @@ kind: "package-bundle" SDK 使用 base 默认提供的 `read`、`write` 和 `edit`。要添加 `str_replace_editor`,请使用 [base 配置指南](../base/README.zh.md#use-this-package)中的显式插入 patch。独立的 `sdk-minimal` profile 自行决定其工具选择。 -打包的 Python runtime 默认启用 [Office 运行时查询与 skills](../../skill/tool-workspace-dependencies/README.zh.md#use-this-package)。`DSH_PRIMARY_RUNTIME` 覆盖随包 payload;空值禁用两行。没有载体默认路径的源码启动仍需显式启用。工具原位读取 payload。profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先于随包 skills。打包与配置见 [runtime 包](../../../python/sdk-runtime/README.zh.md)。 +Python 运行时向本 profile 提供显式下载的创作资源和 Office sidecar。`DSH_PRIMARY_RUNTIME` 覆盖创作载荷;空值禁用其查询。Office skills 可独立使用任一种资源。Profile patch 可独立于 `workspace-dependencies` 禁用 `skill-office` 或替换其 `assetRoot`;同名文件系统 skills 优先。下载与配置见[运行时包](../../../python/sdk-runtime/README.zh.md)。 ----- diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml index 1c0beaae95..004a81ce9b 100644 --- a/packages/bundle/sdk-app/cordis.patch.yml +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -29,7 +29,8 @@ - id: skill-office name: '@deepseek-ai/dsh-skill-office' - disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME)" + disabled: !!js "!(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME) && !process.env.DSH_BUNDLED_OFFICE_CLI" config: - assetRoot: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" - node: !!js "process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" + assetRoot: !!js "process.env.DSH_BUNDLED_OFFICE_SKILLS ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', '..', 'office-skills')" + cli: !!js "process.env.DSH_BUNDLED_OFFICE_CLI === '' ? false : process.env.DSH_BUNDLED_OFFICE_CLI" + node: !!js "process.env.DSH_BUNDLED_OFFICE_NODE ?? process.getBuiltinModule('node:path').resolve(process.env.DSH_PRIMARY_RUNTIME ?? process.env.DSH_BUNDLED_PRIMARY_RUNTIME ?? '', 'dependencies', 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node')" diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index 766f49137b..5780264493 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/README.i18n.yaml @@ -6,14 +6,14 @@ en: 1ea1d8e6d2c14b02 zh: 8e0b9903158667a1 /deepseek-harness-runtime-bin/installed-commands-and-artifacts: - en: af4e7919327e1991 - zh: b4114823b4f1a3fa + en: 89c97974ad5e97a6 + zh: af99634672c80994 /deepseek-harness-runtime-bin/python-module-api: - en: a88b7574e0e00a28 - zh: 3fd4fe8c0b338e79 + en: 78a200e328d96637 + zh: f3b7a0b69b7a39e0 /deepseek-harness-runtime-bin/packaged-profile-resolution: - en: 641c647efc49c568 - zh: a29371de9fbbebfd + en: f1123b8835b57bbe + zh: 05af612c33c5d855 /deepseek-harness-runtime-bin/build-and-distribution: - en: 437ab8ba3bbe7ed6 - zh: 3f321a73e472220b + en: 01b6d838deae9c7b + zh: 88b09ab5f1ae14ca diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 8c82991c02..c3d32f9b3e 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -10,11 +10,22 @@ The wheel installs a `dsh` console command and the `deepseek_harness_runtime` Py Production executables are named `deepseek-harness-sdk-runtime--` under the module's `runtime/` directory; Windows uses the `.exe` suffix. Linux and macOS wheels include a target-native `-rg` sidecar, Windows includes `-rg.exe`, and macOS also includes `-spawn-helper` for `node-pty`. Published targets are Linux x64, Linux arm64, macOS arm64, macOS x64, and Windows x64. The wheel tag and payload must match exactly; no Windows arm64 wheel is published. -Each target also requires `-office/`, where the stem excludes `.exe`. This directory contains the complete installed Office packages and their dependencies, preserving engine resources, manifests, licenses, source inventories, and helper permissions. Copy this directory together with the executable. A missing target engine fails the sidecar build with its npm package name and target platform/architecture. +The wheel carries only the executable, native execution helpers, and `-/` with `downloads.json` and the lightweight `office-skills/` tree. CPython, authoring libraries, standalone Node, pnpm, and the complete Office sidecar are optional downloads. Importing Python modules, starting SDK profiles, and processing Office files never download resources. -Each wheel also includes `-/primary-runtime/` (CPython, locked Office Python libraries, standalone Node, and pnpm) and sibling `office-skills/` (three default workflows and their shared checker). These are ordinary relocatable files, not bytes embedded in the executable. The shared builder selects target-native archives for all five wheel targets and executes its smoke on the native build host. Packaging and installed-runtime lookup reject missing resources, wrong-platform metadata, and lost Python or Node executable permissions. The short platform directory avoids repeating the executable name in Python DLL paths on Windows. +Download either resource explicitly before using it: -The packaged bootstrap supplies `DSH_BUNDLED_PRIMARY_RUNTIME` as a carrier default. The `sdk` profile uses it when `DSH_PRIMARY_RUNTIME` is unset; an explicit path overrides it, and an empty string disables the query and Office provider. External payloads retain the `primary-runtime/` plus sibling `office-skills/` layout. Loaded skills expose the bundled Node and adjacent Office CLI as absolute paths; custom Python-only payloads must patch `skill-office.config.cli: false` or supply `skill-office.config.node`. The SDK reads Python in place without copying it into `DSH_HOME`. Source and dev-only Node carriers have no bundled default; they use an explicit `DSH_PRIMARY_RUNTIME`. +```py +from deepseek_harness_runtime import download_office, download_primary_runtime + +office = download_office() +primary_runtime = download_primary_runtime() +``` + +`download_office()` downloads the locked npm Kit, target engine, and complete dependency tree, plus standalone Node for the Kit CLI. `download_primary_runtime()` independently downloads CPython, locked Python libraries, Node, pnpm, and Office skills. Both return absolute resource directories and reuse completed caches. The authoring download validates its target, interpreters, library directory and skills before returning. The existing local `installPrimaryRuntime()` operation still copies an already available payload; it does not download. + +`DSH_RESOURCE_CACHE` selects an absolute cache root, defaulting to `~/.cache/deepseek-harness/resources`. Resource identities include the release's locked inputs. Downloads verify archive hashes and package identities, preserve executable permissions, and publish completed directories atomically. Failed downloads can be retried. `DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS` bounds each archive download, including its body; the default is `300000`, and accepted values are integers from `1` to `2147483647`. Downloads follow the Harness proxy environment policy. Installed wheel files are never modified. Release versions have separate cache directories; unused versions require manual removal. Downloads use the locked upstream URLs without a registry or mirror override. + +On subsequent launches the packaged bootstrap exposes downloaded resources to the SDK profile. `DSH_PRIMARY_RUNTIME` overrides the cached authoring environment; an empty value disables its query. `DSH_OFFICE_SIDECAR` can select an absolute sidecar directory with its standalone Node. The Office checker is included in the wheel; users may provide their own Python environment and document libraries without downloading the authoring environment. Office skills are available when either resource is selected; authoring-only deployments expose the skills with the Kit CLI disabled. External authoring payloads retain the `primary-runtime/` plus sibling `office-skills/` layout. The SDK reads resources in place without copying them into `DSH_HOME`. The `sdk-minimal` profile does not mount these providers. Skill selection is independent of delivery: project, custom-directory, and user filesystem skills override same-name bundled skills. An SDK patch can disable only the Office provider while retaining the Python query: @@ -25,7 +36,7 @@ Skill selection is independent of delivery: project, custom-directory, and user To replace the three Office workflows and shared checker as a set, patch `skill-office.config.assetRoot` to another absolute resource directory. Use the filesystem skill provider for arbitrary skill collections. Configuration patches apply at process startup; changing skills does not require rebuilding the runtime wheel or Python environment. -Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. +Repository builds also materialize a dev-only `runtime/node/` carrier. It runs `node runtime/node/runtime-bootstrap.mjs` on system Node 22.19 or newer. It is never selected automatically and is excluded from wheels and sdists. Both carriers execute the same `dsh` grammar and shipped profiles, including the standalone `sdk-minimal` tree and the full `web` profile with its frontend assets. The private `dsh-python-runtime-closure` manifest defines the packaged dependency closure; there is no Python-specific Node application or checked-in default `cordis.yml`. @@ -34,6 +45,9 @@ Both carriers execute the same `dsh` grammar and shipped profiles, including the - `bundled_package_dir() -> Path` returns the installed module-data root and verifies its release metadata. - `bundled_runtime_path() -> Path` returns the current platform executable and verifies required sidecars. - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` returns the executable argv by default. Explicit `mode="node"` or `DSH_RUNTIME_MODE=node` selects the repo-only Node carrier. +- `download_office() -> Path` explicitly downloads or reuses the Office sidecar and its standalone Node. +- `download_primary_runtime() -> Path` explicitly downloads or reuses the authoring environment. +- `resolve_office_launch_args() -> tuple[str, str]` locates the installed Node and Kit CLI without downloading; missing resources raise `FileNotFoundError` with the download instruction. - `main()` implements the installed `dsh` console command and rejects an absent or blank `DSH_HOME`. On Windows it waits for the bundled process with inherited standard streams and forwards its exit status; on POSIX it replaces the Python process. Unsupported platforms and missing executables or sidecars raise `FileNotFoundError` with the build and installation routes. Unknown runtime modes raise `ValueError`. @@ -42,7 +56,7 @@ Unsupported platforms and missing executables or sidecars raise `FileNotFoundErr `dsh` initializes shipped profiles under the explicit home, composes their bundle patches, and loads bundled plugins from the executable's virtual filesystem. Runtime resolution uses an in-memory generation instead of disk symlinks or proxy packages. Fallback imports use recorded declaring-package paths, including paths inside the executable's virtual filesystem, so built-in rows and external plugin peers share the bundled Cordis/module instance. Native shared libraries and Windows ConPTY addons are packaged with native addons, while ripgrep and the macOS PTY helper remain executable sidecars. -The Python bootstrap resolves the Office kit from its adjacent directory so native helpers and URL Workers use real filesystem paths. The kit owns engine selection and validation; the Python bootstrap adds no runtime download or compilation. The Office smoke also retrieves CLI paths from the loaded skill and executes capabilities and DOCX conversion with an empty PATH. +Python and the packaged bootstrap share the resource layout: `//office/`, `node/bin/node[.exe]`, and `node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js`. Automatic Office cache discovery requires the `complete` marker to match the manifest identity; an explicit `DSH_OFFICE_SIDECAR` requires the Node and CLI files. The Python bootstrap resolves the Office kit from its explicitly downloaded or selected sidecar so native helpers and URL Workers use real filesystem paths. The same resource preparation implementation serves repository builds and explicit Python downloads. The kit owns document operations; the Office smoke retrieves CLI paths from the loaded skill and executes capabilities and DOCX conversion with an empty PATH. External profile management uses `dsh plugin --profile ...`. That command requires `pnpm` on `PATH`; ordinary SDK/profile execution does not. @@ -50,6 +64,6 @@ External profile management uses `dsh plugin --profile ...`. That command Production deployment permits unused workspace patches for packages outside the runtime closure; patches for included packages must still apply successfully. This exception is confined to the deploy command; repository installation still rejects unused patches. -From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. Deployment disables workspace hoisting so unrelated source packages stay out of the payload. Staging shares root workspace packages with nested consumers of the same pnpm dependency identity, preserving singleton services while retaining different peer resolutions. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. +From the repository root, `pnpm exec tsx scripts/build-exe-for-python-sdk.ts` verifies the closure, builds packages, deploys a symlink-free tree, packages the selected target, and syncs the executable and sidecars into this module. Only the target platform’s PTY prebuilds are packaged. Package README, CHANGELOG, HISTORY Markdown, source maps, and TypeScript declarations outside runtime assets are omitted. Deployment disables workspace hoisting, and packaging excludes checkout dependencies and workspace source trees outside the deployment so dependency scans cannot add development packages to the payload. Staging shares root workspace packages with nested consumers of the same pnpm dependency identity, preserving singleton services while retaining different peer resolutions. `scripts/build-python-release.py` stages release-shaped wheels at the root repository version and pins `deepseek-harness-sdk` to the exact runtime version. -The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the complete target payload and converts DOCX with the required platform engine: the target’s declared native engine, or WASM when no native engine is declared. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). +The installed-wheel smoke creates a clean virtual environment outside the checkout, proves the installed distribution and executable identities, then exercises default and customized SDK profiles, external plugins, MCP, native tools, direct JSON-RPC, committed snapshots, and the real provider on trusted runs. Its Office scenario relocates the lightweight target payload, explicitly downloads the sidecar, and converts DOCX with the required platform engine: the target’s declared native engine, or WASM when no native engine is declared. See the [Python contributor workflow](../development.md) and [installed-wheel testing decision](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.md). diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 27405eaf74..8f2c1bf317 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -10,11 +10,22 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 生产可执行程序位于模块的 `runtime/` 目录,命名为 `deepseek-harness-sdk-runtime--`;Windows 使用 `.exe` 后缀。Linux 与 macOS wheel 包含目标平台原生的 `-rg` 伴随程序,Windows 包含 `-rg.exe`,macOS 还包含 `node-pty` 使用的 `-spawn-helper`。已发布目标是 Linux x64、Linux arm64、macOS arm64、macOS x64 与 Windows x64。wheel 包标签必须与载荷严格匹配;不发布 Windows arm64 wheel 包。 -每个目标还要求 `-office/`,其中 stem 不含 `.exe`。该目录包含完整的已安装 Office 包及其依赖,保留引擎资源、清单、许可证、源码清单与辅助程序权限。复制可执行文件时必须一并复制此目录。缺少目标引擎会使 sidecar 构建失败,错误会指出其 npm 包名与目标平台/架构。 +wheel 仅携带可执行程序、原生执行辅助程序,以及包含 `downloads.json` 和轻量 `office-skills/` 目录的 `-/`。CPython、创作库、独立 Node、pnpm 和完整 Office sidecar 均为可选下载资源。导入 Python 模块、启动 SDK profile 和处理 Office 文件都不会下载资源。 -每个 wheel 还包含 `-/primary-runtime/`(CPython、锁定版本的 Office Python 库、独立 Node 和 pnpm)及同级 `office-skills/`(三个默认工作流与共用检查脚本)。它们是可重定位的普通文件,不嵌入可执行文件。共享构建器为全部五个 wheel 目标选择原生归档,并在对应构建主机上执行冒烟检查。打包与已安装运行时定位会拒绝缺失资源、平台不符的元数据及 Python 或 Node 执行权限丢失。 较短的平台目录避免在 Windows Python DLL 路径中重复可执行文件名称。 +使用前显式下载所需的任一种资源: -打包启动器通过 `DSH_BUNDLED_PRIMARY_RUNTIME` 提供载体默认路径。`sdk` profile 在未设置 `DSH_PRIMARY_RUNTIME` 时使用该路径;显式路径覆盖它,空字符串禁用查询与 Office 提供方。外部 payload 保持 `primary-runtime/` 与同级 `office-skills/` 布局。加载后的技能给出内置 Node 与伴随 Office CLI 的绝对路径;自定义的纯 Python payload 必须配置 `skill-office.config.cli: false` 或提供 `skill-office.config.node`。SDK 原位读取 Python,不复制到 `DSH_HOME`。源码与仅供开发的 Node 载体没有随包默认路径,需显式设置 `DSH_PRIMARY_RUNTIME`。 +```py +from deepseek_harness_runtime import download_office, download_primary_runtime + +office = download_office() +primary_runtime = download_primary_runtime() +``` + +`download_office()` 下载锁定版本的 npm Kit、目标引擎及完整依赖树,并准备运行 Kit CLI 的独立 Node。`download_primary_runtime()` 独立下载 CPython、锁定的 Python 库、Node、pnpm 和 Office skills。两者返回绝对资源目录,并复用完整缓存。创作下载会在返回前校验目标平台、解释器、库目录与 skills。现有本地 `installPrimaryRuntime()` 操作仍只复制已有载荷,不执行下载。 + +`DSH_RESOURCE_CACHE` 指定绝对缓存根目录,默认为 `~/.cache/deepseek-harness/resources`。资源身份包含发布版本锁定的输入。下载会校验归档哈希和包身份、保留执行权限,并原子发布完整目录。下载失败后可重试。`DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS` 限制每个归档的下载耗时,包含响应内容;默认值为 `300000`,接受 `1` 到 `2147483647` 的整数。下载遵循 Harness 代理环境策略。已安装的 wheel 文件不会被修改。不同发布版本使用独立缓存目录;不再使用的版本需手动删除。下载使用锁定的上游 URL,不提供 registry 或镜像覆盖。 + +后续启动时,打包 bootstrap 将已下载资源提供给 SDK profile。`DSH_PRIMARY_RUNTIME` 覆盖缓存中的创作环境;空值禁用其查询。`DSH_OFFICE_SIDECAR` 可选择包含独立 Node 的绝对 sidecar 目录。Office checker 随 wheel 提供;用户可自行提供 Python 环境和文档库,无需下载创作环境。选择任一种资源即可使用 Office skills;仅有创作环境时,skills 中的 Kit CLI 被禁用。外部创作载荷保留 `primary-runtime/` 及同级 `office-skills/` 布局。SDK 原位读取资源,不复制到 `DSH_HOME`。`sdk-minimal` profile 不挂载这些提供方。 选择 skills 与交付资源相互独立:项目、自定义目录和用户文件系统 skills 优先于同名随包 skills。SDK patch 可以仅禁用 Office 提供方,同时保留 Python 查询: @@ -25,7 +36,7 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python 要成套替换三个 Office 工作流与共用检查脚本,可将 `skill-office.config.assetRoot` 配置为另一个绝对资源目录。任意 skill 集合通过文件系统 skill 提供方加载。配置 patch 在进程启动时生效;切换 skills 无需重建 runtime wheel 或 Python 环境。 -仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。系统不会自动选择它,而且 wheel 包与 sdist 均不包含它。 +仓库构建还会物化仅限开发的 `runtime/node/` 载体。它在系统 Node 22.19 或更高版本上运行 `node runtime/node/runtime-bootstrap.mjs`。系统不会自动选择它,而且 wheel 包与 sdist 均不包含它。 两种载体执行相同的 `dsh` 语法与随附 profile,包括独立的 `sdk-minimal` 配置树,以及包含前端产物的完整 `web` profile。私有 `dsh-python-runtime-closure` manifest(元数据清单)定义打包依赖闭包;不存在 Python 专用 Node 应用或检入的默认 `cordis.yml`。 @@ -34,6 +45,9 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python - `bundled_package_dir() -> Path` 返回已安装模块数据根目录,并校验发布元数据。 - `bundled_runtime_path() -> Path` 返回当前平台可执行程序,并校验必需伴随文件。 - `resolve_bundled_launch_args(mode=None) -> tuple[str, ...]` 默认返回可执行程序 argv。显式 `mode="node"` 或 `DSH_RUNTIME_MODE=node` 会选择仅限仓库使用的 Node 载体。 +- `download_office() -> Path` 显式下载或复用 Office sidecar 及其独立 Node。 +- `download_primary_runtime() -> Path` 显式下载或复用创作环境。 +- `resolve_office_launch_args() -> tuple[str, str]` 定位已安装的 Node 和 Kit CLI,不执行下载;资源缺失时抛出含下载指令的 `FileNotFoundError`。 - `main()` 实现已安装的 `dsh` 控制台命令,并拒绝缺失或空白的 `DSH_HOME`。在 Windows 上,它让打包进程继承标准流,等待其结束并转发退出状态;在 POSIX 上,它替换 Python 进程。 不支持的平台以及缺失的可执行程序或伴随文件会抛出 `FileNotFoundError`,并指出构建与安装路径。未知运行时模式会抛出 `ValueError`。 @@ -42,7 +56,7 @@ wheel 包会安装 `dsh` 控制台命令和 `deepseek_harness_runtime` Python `dsh` 在显式指定的主目录下初始化随附 profile、组合其 bundle patch,并从可执行程序的虚拟文件系统加载内置插件。运行时解析使用内存中的 generation,不创建磁盘符号链接或代理包。fallback 导入使用记录的声明包路径,包括可执行程序虚拟文件系统内的路径,因此内置配置项与外部插件 peer 共享内置的 Cordis/模块实例。原生共享库与 Windows ConPTY addon 会同其他原生 addon 一起打包;ripgrep 与 macOS PTY helper 仍是可执行伴随程序。 -Python bootstrap 从相邻目录解析 Office kit,让原生辅助程序与 URL Worker 使用真实文件系统路径。kit 负责引擎选择与校验;Python bootstrap 不增加运行时下载或编译。Office smoke 还从已加载技能获取 CLI 路径,并在空 PATH 下执行 capabilities 和 DOCX 转换。 +Python 与打包的 bootstrap 共享资源布局:`//office/`、`node/bin/node[.exe]` 与 `node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js`。自动发现 Office 缓存要求 `complete` 标记匹配清单 identity;显式 `DSH_OFFICE_SIDECAR` 要求 Node 与 CLI 文件存在。Python bootstrap 从显式下载或选择的 sidecar 解析 Office kit,让原生辅助程序与 URL Worker 使用真实文件系统路径。仓库构建与显式 Python 下载共用资源准备实现。kit 负责文档操作;Office smoke 从已加载技能获取 CLI 路径,并在空 PATH 下执行 capabilities 和 DOCX 转换。 外部 profile 管理使用 `dsh plugin --profile ...`。该命令要求 `PATH` 中存在 `pnpm`;普通 SDK/profile 运行不需要它。 @@ -50,6 +64,6 @@ Python bootstrap 从相邻目录解析 Office kit,让原生辅助程序与 URL 生产部署允许工作区中不属于运行时闭包的补丁保持未使用;闭包内包的补丁仍必须成功应用。此例外仅用于部署命令,仓库安装仍拒绝未使用的补丁。 -在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。部署会关闭工作区提升,避免无关源码包进入载荷。暂存步骤让使用相同 pnpm 依赖标识的嵌套消费者共享根目录的工作区包,保留服务单例,同时保留不同的对等依赖解析。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 +在仓库根目录运行 `pnpm exec tsx scripts/build-exe-for-python-sdk.ts`,会校验闭包、构建包、部署无符号链接的文件树、打包所选目标,并把可执行程序及伴随文件同步到本模块。打包时只保留目标平台的 PTY 预构建文件,并排除运行时资源目录之外的包内 README、CHANGELOG、HISTORY Markdown 文档、source map 和 TypeScript 声明。部署会关闭工作区提升,打包时还会排除部署目录之外的检出依赖和工作区源码树,防止依赖扫描把开发包加入载荷。暂存步骤让使用相同 pnpm 依赖标识的嵌套消费者共享根目录的工作区包,保留服务单例,同时保留不同的对等依赖解析。`scripts/build-python-release.py` 按仓库根版本暂存发布形态的 wheel 包,并将 `deepseek-harness-sdk` 固定到完全相同的运行时版本。 -已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移完整的目标载荷目录,并使用所需的平台引擎转换 DOCX:使用目标已声明的原生引擎,未声明原生引擎时使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 +已安装 wheel 包冒烟测试会在检出目录外创建干净的虚拟环境,验证分发物与可执行程序的来源,然后覆盖默认及自定义 SDK profile、外部插件、MCP、原生工具、直接 JSON-RPC、检入快照,以及可信运行中的真实提供方。Office 场景会迁移轻量目标载荷、显式下载 sidecar,并使用所需的平台引擎转换 DOCX:使用目标已声明的原生引擎,未声明原生引擎时使用 WASM。另见 [Python 贡献者工作流](../development.zh.md) 与 [installed-wheel 测试决策](../../.agents/notes/implemented/testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)。 diff --git a/python/sdk-runtime/hatch_build.py b/python/sdk-runtime/hatch_build.py index f1357bff52..eaee407bce 100644 --- a/python/sdk-runtime/hatch_build.py +++ b/python/sdk-runtime/hatch_build.py @@ -86,8 +86,6 @@ class RuntimeBuildHook(BuildHookInterface): ) if "-macos-" in expected_executable: expected_files.append(f"{expected_executable}-spawn-helper") - office = runtime_dir / f"{expected_executable.removesuffix('.exe')}-office" - expected_files.append(office.name) resources = runtime_dir / target expected_files.append(resources.name) expected_files.sort() @@ -98,20 +96,9 @@ class RuntimeBuildHook(BuildHookInterface): ) for executable in runtime_files: if executable == resources: - validate = runpy.run_path(str(runtime_dir.parent / "_resources.py"))["validate_resources"] + validate = runpy.run_path(str(runtime_dir.parent / "_resources.py"))["validate_downloads"] validate(resources, target) continue - if executable == office: - adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" - if not adapter.is_file(): - raise RuntimeError(f"runtime Office dependency is missing: {adapter}") - native = target.replace("win-", "win32-").replace("macos-", "darwin-") - declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {}) - engine = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm" - required = office / "node_modules" / f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json" - if not required.is_file(): - raise RuntimeError(f"runtime Office dependency is missing: {required}") - continue if not executable.is_file(): raise RuntimeError(f"runtime executable is not a file: {executable}") if platform_tag != "win_amd64" and executable.stat().st_mode & stat.S_IXUSR == 0: diff --git a/python/sdk-runtime/runtime-bootstrap.mjs b/python/sdk-runtime/runtime-bootstrap.mjs index 52836d746e..d860ccafca 100644 --- a/python/sdk-runtime/runtime-bootstrap.mjs +++ b/python/sdk-runtime/runtime-bootstrap.mjs @@ -1,20 +1,78 @@ #!/usr/bin/env node /** Private entry owned by the Python single-file runtime packaging. */ +import { existsSync } from 'node:fs' +import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { homedir } from 'node:os' import { registerHooks } from 'node:module' -import { dirname, join } from 'node:path' +import { dirname, isAbsolute, join } from 'node:path' import { isSea } from 'node:sea' import { fileURLToPath, pathToFileURL } from 'node:url' -if (isSea()) { - // Office spawns executable helpers and URL workers; its complete package tree must be real files. - const parentURL = pathToFileURL(`${process.execPath.replace(/\.exe$/i, '')}-office/package.json`).href +const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'win' : process.platform +const resourceDirectory = join(isSea() ? dirname(process.execPath) : dirname(dirname(fileURLToPath(import.meta.url))), `${platform}-${process.arch}`) +let downloads +let cache +if (existsSync(join(resourceDirectory, 'downloads.json'))) { + downloads = JSON.parse(await readFile(join(resourceDirectory, 'downloads.json'), 'utf8')) + const root = process.env.DSH_RESOURCE_CACHE ?? join(homedir(), '.cache', 'deepseek-harness', 'resources') + if (!isAbsolute(root)) throw new Error('DSH_RESOURCE_CACHE must be an absolute directory') + if (!/^[a-f0-9]{64}$/u.test(downloads.identity)) throw new Error('runtime download manifest has an invalid cache identity') + cache = join(root, downloads.identity) +} + +/** Publish a complete immutable resource directory; overlapping downloads may share the winner. */ +async function downloadResource(kind) { + if (downloads === undefined) throw new Error('runtime download manifest is missing; rebuild or reinstall the runtime wheel') + const destination = join(cache, kind) + if (!existsSync(join(destination, 'complete'))) { + await mkdir(cache, { recursive: true }) + const staging = await mkdtemp(join(cache, `.${kind}-`)) + let disposeProxy + try { + const { installProxyFromEnvironment } = await import('@deepseek-ai/dsh-http-proxy') + disposeProxy = await installProxyFromEnvironment({ get(name) { + const value = process.env[name] + return value === undefined ? undefined : { value } + } }, message => console.error(message)) + const { downloadNodeRuntime, preparePrimaryRuntime } = await import('./primary-runtime.mjs') + const archives = join(cache, 'archives') + if (kind === 'office') { + const { downloadOfficeSidecar } = await import('./office-sidecar.mjs') + await downloadNodeRuntime(downloads.target, join(staging, 'node'), archives) + await downloadOfficeSidecar(downloads.office, staging, archives) + } else { + await preparePrimaryRuntime({ target: downloads.target, output: staging, cache: archives, + version: downloads.version, pnpmArchive: downloads.pnpm, skillSource: join(resourceDirectory, 'office-skills') }) + } + await writeFile(join(staging, 'complete'), downloads.identity) + try { await rename(staging, destination) } catch (error) { + if (!['EEXIST', 'ENOTEMPTY', 'EPERM'].includes(error.code) + || await readFile(join(destination, 'complete'), 'utf8') !== downloads.identity) throw error + } + } finally { + try { await disposeProxy?.() } finally { await rm(staging, { recursive: true, force: true }) } + } + } + if (await readFile(join(destination, 'complete'), 'utf8') !== downloads.identity) throw new Error(`runtime cache identity mismatch: ${destination}`) + return kind === 'primary' ? join(destination, 'primary-runtime') : destination +} + +const office = process.env.DSH_OFFICE_SIDECAR ?? (cache !== undefined && existsSync(join(cache, 'office', 'complete')) ? join(cache, 'office') : undefined) +if (office !== undefined) { + if (!isAbsolute(office)) throw new Error('DSH_OFFICE_SIDECAR must be an absolute directory') + const parentURL = pathToFileURL(join(office, 'package.json')).href registerHooks({ resolve(specifier, context, nextResolve) { - const office = specifier === '@deepseek-ai/libreoffice-kit' || specifier === '@deepseek-ai/libreoffice-kit/package.json' - return nextResolve(specifier, office ? { ...context, parentURL } : context) + const kit = specifier === '@deepseek-ai/libreoffice-kit' || specifier === '@deepseek-ai/libreoffice-kit/package.json' + return nextResolve(specifier, kit ? { ...context, parentURL } : context) }, }) + process.env.DSH_BUNDLED_OFFICE_NODE = join(office, 'node', 'bin', process.platform === 'win32' ? 'node.exe' : 'node') + process.env.DSH_BUNDLED_OFFICE_CLI = join(office, 'node_modules', '@deepseek-ai', 'libreoffice-kit', 'lib', 'cli.js') +} else if (isSea()) { + process.env.DSH_BUNDLED_OFFICE_CLI = '' } +if (downloads !== undefined) process.env.DSH_BUNDLED_OFFICE_SKILLS = join(resourceDirectory, 'office-skills') const selectorName = 'DSH_SUBPROCESS_RUNNER' const selection = process.env[selectorName] @@ -22,17 +80,21 @@ const aclRunner = process.platform === 'win32' ? fileURLToPath(import.meta.resolve('@deepseek-ai/dsh-sandbox-windows-acl/runner')) : undefined -if (aclRunner !== undefined && process.argv[2] === aclRunner) { +const resource = process.env.DSH_RUNTIME_DOWNLOAD +if (resource !== undefined) { + Reflect.deleteProperty(process.env, 'DSH_RUNTIME_DOWNLOAD') + if (resource !== 'office' && resource !== 'primary') throw new Error('DSH_RUNTIME_DOWNLOAD must select office or primary') + const path = await downloadResource(resource) + console.log(JSON.stringify(path)) +} else if (aclRunner !== undefined && process.argv[2] === aclRunner) { process.argv.splice(1, 1) await import('@deepseek-ai/dsh-sandbox-windows-acl/runner') } else if (process.env.DSH_PTC_RUNTIME_NODE === '1') { Reflect.deleteProperty(process.env, 'DSH_PTC_RUNTIME_NODE') await import('@deepseek-ai/dsh-ptc-runtime-node/process') } else if (selection === undefined) { - if (isSea()) { - // Carrier default stays separate so process/home environment and profile patches can override it. - const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'win' : process.platform - process.env.DSH_BUNDLED_PRIMARY_RUNTIME = join(dirname(process.execPath), `${platform}-${process.arch}`, 'primary-runtime') + if (cache !== undefined && existsSync(join(cache, 'primary', 'complete'))) { + process.env.DSH_BUNDLED_PRIMARY_RUNTIME = join(cache, 'primary', 'primary-runtime') } const { runCli } = await import('@deepseek-ai/dsh/lib/bin.js') await runCli() diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index 174d629160..bd853f73b1 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -10,7 +10,7 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node - runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`` on a + runtime/node/runtime-bootstrap.mjs`` on a system Node >= 22.19. It is the current checkout's source build, never selected automatically, and excluded from wheel/sdist distributions. @@ -28,7 +28,7 @@ import shutil import subprocess import sys from pathlib import Path -from ._resources import validate_resources +from ._resources import validate_downloads, validate_resources, office_launch_args PACKAGE_METADATA_FILENAME = "deepseek-harness-runtime.json" @@ -59,10 +59,9 @@ def bundled_runtime_path() -> Path: """Absolute path of the bundled single-file runtime executable for the current platform. Raises FileNotFoundError when the platform is unsupported, the executable - has not been placed into this package, the ripgrep or Office sidecar is - missing, the authoring resources are incomplete, or the required macOS - spawn helper is missing. Invalid authoring metadata or Python permissions - raise ValueError. Missing executable/Office messages name + has not been placed into this package, the ripgrep sidecar or download + manifest is missing, or the required macOS spawn helper is missing. + Invalid download metadata raises ValueError. Missing executable messages name the acquisition routes (acquisition strategy is deliberately separate from this lookup interface, so an on-demand download can replace it without touching callers). @@ -92,22 +91,7 @@ def bundled_runtime_path() -> Path: f"deepseek-harness-runtime-bin is missing the node-pty spawn helper at {helper}. " + _EXE_ACQUISITION_HINT ) - office = path.with_name(f"{path.name.removesuffix('.exe')}-office") - adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" - if not adapter.is_file(): - raise FileNotFoundError( - f"deepseek-harness-runtime-bin is missing the Office sidecar at {office}. " - + _EXE_ACQUISITION_HINT - ) - native = tag.replace("win-", "win32-").replace("macos-", "darwin-") - declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {}) - engine = native if f"@deepseek-ai/libreoffice-kit-{native}" in declared else "wasm" - if not (office / "node_modules" / f"@deepseek-ai/libreoffice-kit-{engine}/prebuilds.json").is_file(): - raise FileNotFoundError( - f"deepseek-harness-runtime-bin is missing the Office sidecar engine {engine} at {office}. " - + _EXE_ACQUISITION_HINT - ) - validate_resources(path.with_name(tag), tag) + validate_downloads(path.with_name(tag), tag) return path @@ -152,14 +136,7 @@ def _current_platform_tag() -> str: def _node_launch_args() -> tuple[str, str]: node_root = bundled_package_dir() / "runtime" / "node" - bin_js = ( - node_root - / "node_modules" - / "@deepseek-ai" - / "dsh" - / "lib" - / "bin.js" - ) + bin_js = node_root / "runtime-bootstrap.mjs" if not bin_js.is_file(): raise FileNotFoundError( f"the dev-only node runtime closure is missing at {node_root} " @@ -176,6 +153,46 @@ def _node_launch_args() -> tuple[str, str]: return (node, str(bin_js)) +def _download(resource: str) -> Path: + result = subprocess.run(resolve_bundled_launch_args(), capture_output=True, encoding="utf-8", + env={**os.environ, "DSH_RUNTIME_DOWNLOAD": resource}) + if result.returncode: + raise RuntimeError(result.stderr.strip() or f"resource download failed with exit code {result.returncode}") + path = json.loads(result.stdout) + if not isinstance(path, str) or not Path(path).is_absolute(): + raise ValueError("runtime returned an invalid resource path") + return Path(path) + + +def download_office() -> Path: + """Download the locked npm Office sidecar and standalone Node into the resource cache. + + Return its absolute directory. Reuse completed downloads; failures leave no + published partial installation. DSH_RESOURCE_CACHE selects the cache root. + This explicit operation requires network access on a cache miss, not a + Harness home, agent, or model. Other SDK operations never trigger downloads. + """ + return _download("office") + + +def download_primary_runtime() -> Path: + """Download locked CPython, libraries, Node, pnpm and skills into the resource cache. + + Return the absolute primary-runtime directory. This is independent of the + Office engine download and does not copy resources into a Harness home. + Completed downloads are reused; DSH_RESOURCE_CACHE selects the cache root. + """ + path = _download("primary") + validate_resources(path.parent, _current_platform_tag()) + return path + + +def resolve_office_launch_args() -> tuple[str, str]: + """Locate the explicitly downloaded Office CLI and its standalone Node; never download.""" + tag = _current_platform_tag() + return office_launch_args(bundled_runtime_path().with_name(tag), tag) + + def main() -> None: """Launch the CLI with explicit DSH_HOME; wait on Windows, replace the process on POSIX.""" if not os.environ.get("DSH_HOME", "").strip(): @@ -197,6 +214,9 @@ __all__ = [ "RUNTIME_MODE_ENV_VAR", "bundled_package_dir", "bundled_runtime_path", + "download_office", + "download_primary_runtime", + "resolve_office_launch_args", "main", "resolve_bundled_launch_args", ] diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/_resources.py b/python/sdk-runtime/src/deepseek_harness_runtime/_resources.py index e0f2026cb4..ae547ac26c 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/_resources.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/_resources.py @@ -1,8 +1,12 @@ -"""Validate the authoring resources in staged, installed, or archived runtime wheels.""" +"""Validate wheel download manifests, downloaded resources, and Office process launches.""" from __future__ import annotations import json +import os +import signal +import subprocess +from collections.abc import Sequence import re import stat import zipfile @@ -41,3 +45,77 @@ def validate_resources(root: Path | zipfile.Path, target: str) -> None: if isinstance(binary, zipfile.Path) else binary.stat().st_mode) if mode & stat.S_IXUSR == 0: raise ValueError(f"runtime interpreter lost its executable bit: {binary}") + + +def validate_downloads(root: Path | zipfile.Path, target: str) -> dict: + """Validate the lightweight download manifest carried by one platform wheel.""" + path = root / "downloads.json" + value = json.loads(path.read_text(encoding="utf-8")) + if (not isinstance(value, dict) or value.get("target") != target.replace("macos-", "mac-") + or not isinstance(value.get("identity"), str) + or not re.fullmatch(r"[a-f0-9]{64}", value["identity"]) + or not isinstance(value.get("office"), list) or not value["office"] + or not isinstance(value.get("pnpm"), dict)): + raise ValueError(f"invalid runtime download manifest: {path}") + for file in ["scripts/check_office.py", *(f"office-{kind}/SKILL.md" for kind in ("docx", "pptx", "xlsx"))]: + if not (root / "office-skills" / file).is_file(): + raise FileNotFoundError(f"runtime Office skill is missing: {root / 'office-skills' / file}") + return value + + +def office_launch_args(root: Path, target: str) -> tuple[str, str]: + """Require an explicitly installed sidecar, including its independent Node interpreter.""" + metadata = validate_downloads(root, target) + cache = Path(os.environ.get("DSH_RESOURCE_CACHE", str(Path.home() / ".cache/deepseek-harness/resources"))) + if not cache.is_absolute(): + raise ValueError("DSH_RESOURCE_CACHE must be an absolute directory") + office = Path(os.environ.get("DSH_OFFICE_SIDECAR", str(cache / metadata["identity"] / "office"))) + if not office.is_absolute(): + raise ValueError("DSH_OFFICE_SIDECAR must be an absolute directory") + if "DSH_OFFICE_SIDECAR" not in os.environ: + complete = office / "complete" + if not complete.is_file(): + raise FileNotFoundError(f"Office download is incomplete: {office}. Call deepseek_harness_runtime.download_office() first.") + if complete.read_text(encoding="utf-8") != metadata["identity"]: + raise ValueError(f"Office cache identity mismatch: {office}") + node = office / "node/bin" / ("node.exe" if target == "win-x64" else "node") + cli = office / "node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js" + for path in (node, cli): + if not path.is_file(): + raise FileNotFoundError(f"Office resource is missing: {path}. Call deepseek_harness_runtime.download_office() first.") + return str(node), str(cli) + + +def run_office_process(arguments: Sequence[str]) -> subprocess.CompletedProcess[bytes]: + """Run Office with captured streams; a second interrupt kills its process tree.""" + taskkill = os.path.join(os.environ.get("SystemRoot", ""), "System32", "taskkill.exe") + if os.name == "nt" and not os.path.isabs(taskkill): + raise ValueError("Office operations on Windows require an absolute SystemRoot environment value.") + with subprocess.Popen( + arguments, + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + start_new_session=os.name != "nt", + ) as process: + try: + stdout, stderr = process.communicate() + except KeyboardInterrupt: + # Windows console Ctrl+C already reaches children in the same console. + if os.name != "nt": + process.send_signal(signal.SIGINT) + try: + process.communicate() + except KeyboardInterrupt: + if os.name == "nt": + subprocess.run([taskkill, "/PID", str(process.pid), "/T", "/F"], + stdin=subprocess.DEVNULL, capture_output=True, check=False) + else: + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError as error: + # The CLI and its children can finish before the second interrupt. + pass + process.communicate() + raise + return subprocess.CompletedProcess(arguments, process.returncode, stdout, stderr) diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 8ac185aaf9..0d2460d5ed 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/README.i18n.yaml @@ -5,6 +5,9 @@ /deepseek-harness-python-sdk: en: 43f7de064985e731 zh: 9ad59629660b1f13 +/deepseek-harness-python-sdk/optional-office-operations: + en: ddf4dabea526cef1 + zh: 6e31cab10d2f3ed8 /deepseek-harness-python-sdk/start-a-runtime: en: 9a069c1a7cbb0779 zh: 7f625a802cd1e9ea diff --git a/python/sdk/README.md b/python/sdk/README.md index e186699fd4..78226d3d2e 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -8,6 +8,24 @@ Python subprocess SDK for driving DeepSeek Harness over newline-delimited JSON-R python -m pip install deepseek-harness-sdk ``` +## Optional Office operations + +Download the Office runtime explicitly, then call the local API without an agent, Session, model request, API key, or Harness home: + +```py +from deepseek_harness_runtime import download_office +from deepseek_harness.office import convert, recalculate, render_images + +download_office() +pdf = convert("report.docx", "report.pdf") +workbook = recalculate("forecast.xlsx", "calculated.xlsx") +preview = render_images("report.docx", "report-preview", pages=[1]) +``` + +The output file or image directory must not exist. Successful outputs belong to the caller; Kit removes failed outputs. `OfficeOptions` supplies operation limits and font preferences. Results expose typed paths, missing fonts, and image metadata. `OfficeError` retains CLI failure codes; malformed results raise `SdkProtocolError`. Recalculation does not verify business logic or Excel fidelity. Ctrl+C waits for CLI cleanup; a second interrupt forces termination and may leave partial output. Windows requires an absolute `SystemRoot` and a shared console for Ctrl+C delivery. + +The authoring environment is independently optional through `deepseek_harness_runtime.download_primary_runtime()`. See the [runtime reference](../sdk-runtime/README.md) for cache selection, resource activation, and download behavior. SDK startup and Office operations never acquire missing resources automatically. + ## Start a runtime The Python SDK has no separate application entrypoint. It launches the bundled `dsh` CLI with `--profile sdk`; the selected profile owns the JSON-RPC server, agent composition, credentials, persistence, tools, and shutdown behavior. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index 9176c7d17b..c288da9be1 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -8,6 +8,24 @@ python -m pip install deepseek-harness-sdk ``` +## 可选 Office 操作 + +显式下载 Office 运行时后,即可调用本地 API,无需 agent、Session、模型请求、API key 或 Harness home: + +```py +from deepseek_harness_runtime import download_office +from deepseek_harness.office import convert, recalculate, render_images + +download_office() +pdf = convert("report.docx", "report.pdf") +workbook = recalculate("forecast.xlsx", "calculated.xlsx") +preview = render_images("report.docx", "report-preview", pages=[1]) +``` + +输出文件或图片目录必须不存在。调用方拥有成功输出;Kit 删除失败输出。`OfficeOptions` 提供操作限制和字体偏好。结果包含类型化路径、缺失字体和图片元数据。`OfficeError` 保留 CLI 失败代码;无效结果抛出 `SdkProtocolError`。重算不验证业务逻辑或 Excel 兼容性。Ctrl+C 会等待 CLI 清理;再次中断会强制终止,可能留下未完成输出。Windows 要求绝对 `SystemRoot` 路径,并通过共享控制台传递 Ctrl+C。 + +创作环境可通过 `deepseek_harness_runtime.download_primary_runtime()` 独立选择。缓存选择、资源启用与下载行为见[运行时参考](../sdk-runtime/README.zh.md)。SDK 启动和 Office 操作都不会自动获取缺失资源。 + ## 启动运行时 Python SDK 没有独立的应用入口。它以 `--profile sdk` 启动内置的 `dsh` CLI(命令行界面);所选 profile 负责 JSON-RPC 服务器、agent(智能体)组合、凭据、持久化、工具和关闭流程。 diff --git a/python/sdk/src/deepseek_harness/office.py b/python/sdk/src/deepseek_harness/office.py new file mode 100644 index 0000000000..9e2dab604d --- /dev/null +++ b/python/sdk/src/deepseek_harness/office.py @@ -0,0 +1,219 @@ +"""Local Office operations through the runtime wheel's dsoffice CLI. + +Calls are synchronous and do not create agents, sessions, or model requests. +The CLI owns conversion deadlines, engine termination, and failed-output cleanup. +Console Ctrl+C waits for CLI cleanup before raising KeyboardInterrupt; a second +interrupt forces termination and can leave partial output. On Windows, callers +must share the CLI's console for Ctrl+C delivery. +""" + +from __future__ import annotations + +import json +import os +from collections.abc import Sequence +from dataclasses import dataclass, fields +from pathlib import Path +from typing import Literal, TypeVar + +import deepseek_harness_runtime +from deepseek_harness_runtime._resources import run_office_process +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from .errors import HarnessError, SdkProtocolError + + +class OfficeError(HarnessError): + """A failed Office operation, including its CLI error code and exit status.""" + + def __init__(self, code: str, message: str, exit_code: int) -> None: + super().__init__(message) + self.code = code + self.exit_code = exit_code + + +class _OfficeModel(BaseModel): + model_config = ConfigDict(strict=True, frozen=True) + + +class ConversionResult(_OfficeModel): + """Saved conversion or recalculation output and missing declared fonts.""" + + backend: Literal["native", "wasm"] + output_path: Path = Field(alias="outputPath") + missing_fonts: list[str] = Field(alias="missingFonts") + + +class ImageRectangle(_OfficeModel): + """Source rectangle in CSS pixels at 96 DPI, before raster scaling.""" + + x: float + y: float + width: float + height: float + + +class RenderedImage(_OfficeModel): + """One saved PNG and its source page, worksheet, or worksheet fragment.""" + + index: int + path: Path + width: int + height: int + rectangle: ImageRectangle + byte_length: int = Field(alias="byteLength") + page: int | None = None + sheet: str | None = None + range: str | None = None + rtl: bool | None = None + + +class RenderImagesResult(_OfficeModel): + """Image manifest for one saved input; page_count includes unselected pages.""" + + schema_version: Literal[1] = Field(alias="schemaVersion") + backend: Literal["native", "wasm"] + raster_engine: Literal["libreoffice", "pdfium"] = Field(alias="rasterEngine") + source: Literal["saved"] + input_path: Path = Field(alias="inputPath") + source_sha256: str = Field(alias="sourceSha256") + dpi: float + page_count: int = Field(alias="pageCount") + images: list[RenderedImage] + missing_fonts: list[str] = Field(alias="missingFonts") + + +@dataclass(frozen=True, slots=True) +class OfficeOptions: + """Kit limits and fonts; omitted values retain the installed kit defaults. + + timeout_ms bounds the engine operation, including font work and output, + after explicit resource download. The call waits for engine exit and cleanup. + Font paths may be relative to cwd; fallback groups replace the kit defaults. + """ + + timeout_ms: int | None = None + max_input_bytes: int | None = None + max_output_bytes: int | None = None + max_image_resolution: int | None = None + max_archive_entries: int | None = None + max_uncompressed_bytes: int | None = None + max_font_files: int | None = None + max_font_file_bytes: int | None = None + max_loaded_font_bytes: int | None = None + font_directories: Sequence[str | os.PathLike[str]] | None = None + initial_font_families: Sequence[str] | None = None + font_fallbacks: Sequence[Sequence[str]] | None = None + + def _arguments(self) -> list[str]: + arguments: list[str] = [] + for field in fields(self): + value = getattr(self, field.name) + if value is None: + continue + if field.name == "font_directories": + for path in value: + arguments.extend(("--font-directory", os.fspath(path))) + elif field.name == "initial_font_families": + for family in value: + arguments.extend(("--initial-font-family", family)) + elif field.name == "font_fallbacks": + arguments.extend(("--font-fallbacks", json.dumps(value))) + else: + arguments.extend(("--" + field.name.replace("_", "-"), str(value))) + return arguments + + +class _CliFailure(_OfficeModel): + code: str + error: str + + +_Result = TypeVar("_Result", bound=_OfficeModel) + + +def _run(arguments: list[str], options: OfficeOptions | None, result_type: type[_Result]) -> _Result: + process = run_office_process( + [*deepseek_harness_runtime.resolve_office_launch_args(), *arguments, + *(options._arguments() if options is not None else [])], + ) + stdout, stderr = process.stdout, process.stderr + if process.returncode != 0: + try: + failure = _CliFailure.model_validate_json(stderr) + except ValidationError: + raise OfficeError("failed", stderr.decode("utf-8", errors="replace").strip() + or "Office CLI exited without a diagnostic.", + process.returncode) from None + raise OfficeError(failure.code, failure.error, process.returncode) + try: + return result_type.model_validate_json(stdout) + except ValidationError as error: + raise SdkProtocolError("Office CLI returned an invalid result.") from error + + +def render_images( + input_path: str | os.PathLike[str], + output_dir: str | os.PathLike[str], + *, + pages: Literal["all"] | Sequence[int] | None = None, + sheet: str | None = None, + range: str | None = None, + dpi: int | None = None, + max_pages: int | None = None, + max_pixels: int | None = None, + max_dimension: int | None = None, + options: OfficeOptions | None = None, +) -> RenderImagesResult: + """Render Office or PDF to PNG files and manifest.json in a fresh directory. + + Pages are one-based document pages or slides. Worksheets use an exact + sheet name and optional A1 range instead. Paths may be relative to cwd. + The caller owns successful output; failed batches remove the new directory. + Raises OfficeError for CLI failures and SdkProtocolError for invalid JSON. + """ + arguments = ["render", "--input", os.fspath(input_path), "--output-dir", os.fspath(output_dir)] + selections = { + "pages": pages if isinstance(pages, str) or pages is None else ",".join(map(str, pages)), + "sheet": sheet, "range": range, "dpi": dpi, "max-pages": max_pages, + "max-pixels": max_pixels, "max-dimension": max_dimension, + } + for name, value in selections.items(): + if value is not None: + arguments.extend(("--" + name, str(value))) + return _run(arguments, options, RenderImagesResult) + + +def convert( + input_path: str | os.PathLike[str], + output_path: str | os.PathLike[str], + *, + sheet: str | None = None, + options: OfficeOptions | None = None, +) -> ConversionResult: + """Export Office to the format named by a fresh output file's extension. + + Paths may be relative to cwd. CSV export requires an exact sheet name for + multi-sheet input. Existing output is rejected; failed output is removed. + Raises OfficeError for CLI failures and SdkProtocolError for invalid JSON. + """ + arguments = ["convert", "--input", os.fspath(input_path), "--output", os.fspath(output_path)] + if sheet is not None: + arguments.extend(("--sheet", sheet)) + return _run(arguments, options, ConversionResult) + + +def recalculate( + input_path: str | os.PathLike[str], + output_path: str | os.PathLike[str], + *, + options: OfficeOptions | None = None, +) -> ConversionResult: + """Refresh workbook formula caches into a distinct, fresh XLSX or ODS file. + + Input accepts XLS, XLSX, or ODS. Paths may be relative to cwd. Formulas + survive, but recalculation does not verify business logic or Excel fidelity. + Raises OfficeError for CLI failures and SdkProtocolError for invalid JSON. + """ + return _run(["recalculate", "--input", os.fspath(input_path), "--output", os.fspath(output_path)], + options, ConversionResult) diff --git a/python/sdk/tests/fixtures/office_cli.py b/python/sdk/tests/fixtures/office_cli.py new file mode 100644 index 0000000000..51e999a907 --- /dev/null +++ b/python/sdk/tests/fixtures/office_cli.py @@ -0,0 +1,48 @@ +"""Office JSON subprocess peer; engine behavior belongs to the real kit smoke.""" + +import json +import sys +from pathlib import Path + +receipt = Path(sys.argv[1]) +arguments = sys.argv[2:] +receipt.write_text(json.dumps(arguments), encoding="utf-8") +command = arguments[0] +values = dict(zip(arguments[1::2], arguments[2::2])) +source = Path(values["--input"]) +if source.name == "error.docx": + print(json.dumps({"code": "invalid-document", "error": "Document cannot be loaded."}), file=sys.stderr) + sys.exit(1) +if source.name == "crash.docx": + print("engine process unavailable", file=sys.stderr) + sys.exit(7) +if source.name == "invalid.docx": + print(json.dumps({"backend": "native", "outputPath": 3, "missingFonts": []})) + sys.exit(0) +if source.name == "invalid-bytes.docx": + sys.stdout.buffer.write(b"\xff") + sys.exit(0) +if source.name == "timeout.docx": + output = Path(values["--output"]) + output.write_text("partial output", encoding="utf-8") + output.unlink() + print(json.dumps({"code": "timeout", "error": "Conversion timed out."}), file=sys.stderr) + sys.exit(1) +if command == "render": + output = Path(values["--output-dir"]) + output.mkdir() + image = output / "page-1.png" + image.write_bytes(b"png fixture") + result = { + "schemaVersion": 1, "backend": "native", "rasterEngine": "libreoffice", "source": "saved", + "inputPath": str(source.resolve()), "sourceSha256": "abc123", "dpi": 144, "pageCount": 3, + "images": [{"index": 0, "page": 1, "path": str(image.resolve()), "width": 64, "height": 48, + "rectangle": {"x": 0, "y": 0, "width": 32, "height": 24}, "byteLength": 11}], + "missingFonts": ["Fixture Sans"], + } + (output / "manifest.json").write_text(json.dumps(result), encoding="utf-8") +else: + output = Path(values["--output"]) + output.write_text("converted fixture", encoding="utf-8") + result = {"backend": "wasm", "outputPath": str(output.resolve()), "missingFonts": []} +print(json.dumps(result)) diff --git a/python/sdk/tests/test_office.py b/python/sdk/tests/test_office.py new file mode 100644 index 0000000000..e4f5039436 --- /dev/null +++ b/python/sdk/tests/test_office.py @@ -0,0 +1,101 @@ +"""Exercise Office argv and JSON through an actual subprocess without a model.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Callable +from pathlib import Path + +import deepseek_harness_runtime +import pytest + +from deepseek_harness.errors import SdkProtocolError +from deepseek_harness.office import ConversionResult, OfficeError, OfficeOptions, convert, recalculate, render_images + + +@pytest.fixture +def office_cli(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + receipt = tmp_path / "arguments.json" + script = Path(__file__).parent / "fixtures" / "office_cli.py" + monkeypatch.setattr(deepseek_harness_runtime, "resolve_office_launch_args", + lambda: (sys.executable, str(script), str(receipt))) + return receipt + + +@pytest.mark.parametrize("operation", [convert, recalculate]) +def test_saved_output_and_literal_path_arguments( + operation: Callable[..., ConversionResult], office_cli: Path, tmp_path: Path, +) -> None: + source = tmp_path / "工作簿 $name with spaces.xlsx" + output = tmp_path / "saved workbook.xlsx" + result = operation(source, output) + assert result.output_path == output + assert output.read_text() == "converted fixture" + assert result.backend == "wasm" + assert result.missing_fonts == [] + assert json.loads(office_cli.read_text()) == [operation.__name__, "--input", str(source), "--output", str(output)] + + +def test_render_images_reads_the_saved_manifest(office_cli: Path, tmp_path: Path) -> None: + output = tmp_path / "preview" + result = render_images(tmp_path / "deck.pptx", output, pages=[1, 3], dpi=144, max_pages=10, + max_pixels=1_000_000, max_dimension=4096) + assert result.page_count == 3 + assert result.images[0].path.read_bytes() == b"png fixture" + assert result.images[0].rectangle.width == 32 + assert result.images[0].byte_length == 11 + assert result.missing_fonts == ["Fixture Sans"] + assert result.input_path == tmp_path / "deck.pptx" + assert json.loads(office_cli.read_text())[5:] == [ + "--pages", "1,3", "--dpi", "144", "--max-pages", "10", "--max-pixels", "1000000", "--max-dimension", "4096", + ] + + +def test_worksheet_selection_and_csv_sheet_keep_exact_names(office_cli: Path, tmp_path: Path) -> None: + sheet = "收入 $Values" + render_images("workbook.xlsx", tmp_path / "preview", sheet=sheet, range="A1:E9") + assert json.loads(office_cli.read_text())[5:] == ["--sheet", sheet, "--range", "A1:E9"] + convert("workbook.xlsx", tmp_path / "data.csv", sheet=sheet) + assert json.loads(office_cli.read_text())[5:] == ["--sheet", sheet] + + +def test_limits_and_fonts_reach_the_cli(office_cli: Path, tmp_path: Path) -> None: + convert("report.docx", tmp_path / "report.pdf", options=OfficeOptions( + timeout_ms=4567, max_input_bytes=1024, max_output_bytes=2048, max_image_resolution=72, + max_archive_entries=10, max_uncompressed_bytes=4096, max_font_files=20, + max_font_file_bytes=8192, max_loaded_font_bytes=16384, + font_directories=[tmp_path / "Fonts One", tmp_path / "Fonts Two"], + initial_font_families=["Fixture Sans", "中文字体"], font_fallbacks=[["Preferred", "Fallback"]], + )) + assert json.loads(office_cli.read_text())[5:] == [ + "--timeout-ms", "4567", "--max-input-bytes", "1024", "--max-output-bytes", "2048", + "--max-image-resolution", "72", "--max-archive-entries", "10", "--max-uncompressed-bytes", "4096", + "--max-font-files", "20", "--max-font-file-bytes", "8192", "--max-loaded-font-bytes", "16384", + "--font-directory", str(tmp_path / "Fonts One"), "--font-directory", str(tmp_path / "Fonts Two"), + "--initial-font-family", "Fixture Sans", "--initial-font-family", "中文字体", + "--font-fallbacks", '[["Preferred", "Fallback"]]', + ] + + +@pytest.mark.parametrize(("filename", "code", "exit_code", "message"), [ + ("error.docx", "invalid-document", 1, "Document cannot be loaded"), + ("crash.docx", "failed", 7, "engine process unavailable"), + ("timeout.docx", "timeout", 1, "Conversion timed out"), +]) +def test_cli_failures_preserve_diagnostics_after_exit( + office_cli: Path, tmp_path: Path, filename: str, code: str, exit_code: int, message: str, +) -> None: + output = tmp_path / "result.pdf" + with pytest.raises(OfficeError, match=message) as failure: + convert(filename, output, options=OfficeOptions(timeout_ms=100)) + assert failure.value.code == code + assert failure.value.exit_code == exit_code + assert not output.exists() + assert json.loads(office_cli.read_text())[-2:] == ["--timeout-ms", "100"] + + +@pytest.mark.parametrize("filename", ["invalid.docx", "invalid-bytes.docx"]) +def test_invalid_cli_result_fails_at_the_json_reader(office_cli: Path, tmp_path: Path, filename: str) -> None: + with pytest.raises(SdkProtocolError, match="Office CLI returned an invalid result"): + convert(filename, tmp_path / "result.pdf") diff --git a/python/sdk/tests/test_office_interrupts.py b/python/sdk/tests/test_office_interrupts.py new file mode 100644 index 0000000000..2cd4dbd612 --- /dev/null +++ b/python/sdk/tests/test_office_interrupts.py @@ -0,0 +1,106 @@ +"""Office interruption terminates the owned process tree before returning to callers.""" +from __future__ import annotations + +import ntpath +import os +import signal +import subprocess +import sys +import time +from pathlib import Path +from types import SimpleNamespace + +import pytest +from deepseek_harness_runtime import _resources + + +def wait_for_file(path: Path, process: subprocess.Popen, timeout: float = 30) -> None: + deadline = time.monotonic() + timeout + while not path.is_file(): + if process.poll() is not None: + raise AssertionError(f"fixture exited before {path.name}: {process.returncode}") + if time.monotonic() >= deadline: + raise TimeoutError(f"fixture never produced {path}") + time.sleep(0.01) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX process groups and SIGINT delivery") +def test_second_interrupt_waits_for_the_entire_office_process_tree(tmp_path: Path) -> None: + fixture = tmp_path / "office.py" + ready, interrupted, pidfile = (tmp_path / name for name in ("ready", "interrupted", "pid")) + fixture.write_text("""import json,os,signal,subprocess,sys +from pathlib import Path +root=Path(sys.argv[1]) +(root/'pid').write_text(str(os.getpid())) +signal.signal(signal.SIGINT,lambda *_:(root/'interrupted').touch()) +child=subprocess.Popen([sys.executable,'-c', + "import signal,sys; from pathlib import Path; signal.signal(signal.SIGINT,signal.SIG_IGN); Path(sys.argv[1]).touch(); signal.pause()",str(root/'child-ready')]) +(root/'ready').touch() +child.wait() +""", encoding="utf-8") + code = ("import sys; from deepseek_harness_runtime._resources import run_office_process; " + f"args=[sys.executable,{str(fixture)!r},{str(tmp_path)!r}]\n" + "try: run_office_process(args)\n" + "except KeyboardInterrupt: print('tree terminated',flush=True); sys.exit(73)\n") + process = subprocess.Popen([sys.executable, "-c", code], stdout=subprocess.PIPE, stderr=subprocess.PIPE, + start_new_session=True, env={**os.environ, + "PYTHONPATH": str(Path(_resources.__file__).resolve().parents[1])}) + try: + wait_for_file(ready, process) + wait_for_file(tmp_path / "child-ready", process) + os.kill(process.pid, signal.SIGINT) + wait_for_file(interrupted, process) + os.kill(process.pid, signal.SIGINT) + # The grandchild inherits the captured streams; communicate can finish only after it closes them. + stdout, stderr = process.communicate(timeout=30) + assert process.returncode == 73, stderr + assert stdout == b"tree terminated\n" + finally: + if pidfile.is_file(): + try: + os.killpg(int(pidfile.read_text()), signal.SIGKILL) + except ProcessLookupError as error: + # The successful interruption path already terminated this group. + pass + if process.poll() is None: + process.kill() + process.communicate(timeout=30) + + +def test_windows_second_interrupt_uses_absolute_taskkill_and_waits(monkeypatch: pytest.MonkeyPatch) -> None: + calls = [] + + class Process: + pid = 73 + returncode = -9 + attempts = 0 + + def __enter__(self): + return self + + def __exit__(self, *_): + pass + + def communicate(self): + self.attempts += 1 + if self.attempts < 3: + raise KeyboardInterrupt + return b"", b"" + + process = Process() + + def popen(arguments, **options): + assert options == {"stdin": subprocess.DEVNULL, "stdout": subprocess.PIPE, + "stderr": subprocess.PIPE, "start_new_session": False} + return process + + monkeypatch.setattr(_resources, "os", SimpleNamespace(name="nt", path=ntpath, environ={"SystemRoot": r"C:\Windows"})) + monkeypatch.setattr(_resources, "subprocess", SimpleNamespace( + Popen=popen, DEVNULL=subprocess.DEVNULL, PIPE=subprocess.PIPE, + run=lambda arguments, **options: calls.append((arguments, options)), + )) + with pytest.raises(KeyboardInterrupt): + _resources.run_office_process(["node.exe", "cli.js"]) + assert process.attempts == 3 + assert calls == [([r"C:\Windows\System32\taskkill.exe", "/PID", "73", "/T", "/F"], + {"stdin": subprocess.DEVNULL, "capture_output": True, "check": False})] diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index b6ddb572e3..77824c1f72 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -145,7 +145,7 @@ def test_stage_runtime_copies_platform_payload( spawn_helper.write_bytes(b"helper") spawn_helper.chmod(0o755) expected[spawn_helper.name] = b"helper" - office = executable.parent / build_python_release.office_sidecar_name(executable.name) + office = executable.parent / f"{executable.name.removesuffix('.exe')}-office" office_asset = office / "node_modules" / "@deepseek-ai" / "libreoffice-kit-wasm" / "assets" / "soffice.data" office_asset.parent.mkdir(parents=True) office_asset.write_bytes(b"office data") @@ -163,7 +163,7 @@ def test_stage_runtime_copies_platform_payload( for path in runtime_dir.glob("deepseek-harness-sdk-runtime-*") if path.is_file() } == expected - assert (runtime_dir / office.name / office_asset.relative_to(office)).read_bytes() == b"office data" + assert not (runtime_dir / office.name).exists() assert (runtime_dir / resources.name / resource.relative_to(resources)).read_text() == "checker" pyproject = (destination / "pyproject.toml").read_text() assert 'license = "MIT"' in pyproject @@ -191,43 +191,9 @@ def test_stage_runtime_rejects_a_noncanonical_executable_name(tmp_path: Path) -> ) -@pytest.mark.parametrize("platform_tag,selected", [ - ("manylinux_2_28_x86_64", "wasm"), ("macosx_14_0_arm64", "darwin-arm64"), - ("macosx_14_0_x86_64", "darwin-x64"), ("win_amd64", "win32-x64"), - ("manylinux_2_28_x86_64", "linux-x64"), ("macosx_14_0_arm64", "wasm"), -]) -@pytest.mark.parametrize("invalid", [None, "asset", "missing-engine", "foreign-engine", "helper-mode"]) -def test_office_wheel_requires_only_target_engine( - tmp_path: Path, platform_tag: str, selected: str, invalid: str | None, -) -> None: - root = "runtime-office/node_modules" - wheel = tmp_path / "office.zip" - native = selected != "wasm" - if invalid == "helper-mode" and (not native or platform_tag == "win_amd64"): - pytest.skip("executable mode applies to POSIX native helpers") - engine = ({"kind": "native", "executable": "bin/helper"} if native else - {"kind": "wasm", "loader": "loader.cjs", "wasm": "engine.wasm", "data": "engine.data", "metadata": "fonts.json"}) - with zipfile.ZipFile(wheel, "w") as archive: - archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit/package.json", json.dumps({ - "optionalDependencies": {f"@deepseek-ai/libreoffice-kit-{selected}": "0.0.1"}, - })) - base = f"{root}/@deepseek-ai/libreoffice-kit-{selected}" - if invalid != "missing-engine": - archive.writestr(f"{base}/prebuilds.json", json.dumps({"engine": engine})) - for field in (("executable",) if native else ("loader", "wasm", "data", "metadata")): - if invalid == "asset" and field == ("executable" if native else "data"): - continue - asset = zipfile.ZipInfo(f"{base}/{engine[field]}") - asset.external_attr = (0o644 if invalid == "helper-mode" else 0o755) << 16 - archive.writestr(asset, b"payload") - if invalid == "foreign-engine": - foreign = "wasm" if native else "darwin-arm64" - archive.writestr(f"{root}/@deepseek-ai/libreoffice-kit-{foreign}/prebuilds.json", "{}") - with zipfile.ZipFile(wheel) as archive: - if invalid is None: - build_python_release.verify_office_payload(archive, root, platform_tag) - else: - message = {"asset": "asset is missing", "missing-engine": "dependency is missing", - "foreign-engine": "Unexpected Office engine", "helper-mode": "executable bit"}[invalid] - with pytest.raises(RuntimeError, match=message): - build_python_release.verify_office_payload(archive, root, platform_tag) +def test_wheel_verification_rejects_oversized_artifacts(tmp_path: Path) -> None: + wheel = tmp_path / "oversized.whl" + with wheel.open("wb") as output: + output.truncate(100_000_001) + with pytest.raises(RuntimeError, match="PyPI single-file limit"): + build_python_release.verify_wheel(wheel, "runtime", "1.2.3", ("win_amd64", "runtime.exe")) diff --git a/python/sdk/tests/test_runtime_resolution.py b/python/sdk/tests/test_runtime_resolution.py index 6cf7edf742..e1e094c583 100644 --- a/python/sdk/tests/test_runtime_resolution.py +++ b/python/sdk/tests/test_runtime_resolution.py @@ -5,6 +5,7 @@ from __future__ import annotations import json import os import subprocess +import shutil import sys import zipfile from pathlib import Path @@ -36,6 +37,11 @@ def _resource_sidecars(executable: Path, native_targets: tuple[str, ...] = ("dar f"@deepseek-ai/libreoffice-kit-{target}": "0.0.1" for target in (*native_targets, "wasm") }}), encoding="utf-8") resources = executable.with_name(tag) + resources.mkdir(parents=True, exist_ok=True) + (resources / "downloads.json").write_text(json.dumps({ + "target": tag.replace("macos-", "mac-"), "identity": "a" * 64, + "office": [{"name": "fixture"}], "pnpm": {}, + })) manifest = resources / "primary-runtime/runtime.json" manifest.parent.mkdir(parents=True) manifest.write_text(json.dumps({"platform": native.rsplit("-", 1)[0], "arch": tag.rsplit("-", 1)[1], @@ -195,19 +201,20 @@ def test_runtime_requires_complete_resource_sidecars( monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") - with pytest.raises(FileNotFoundError, match="Office sidecar"): + with pytest.raises(FileNotFoundError, match="downloads.json"): runtime.bundled_runtime_path() office = _resource_sidecars(executable) assert runtime.bundled_runtime_path() == executable (office / "node_modules/@deepseek-ai/libreoffice-kit-wasm/prebuilds.json").unlink() - with pytest.raises(FileNotFoundError, match="Office sidecar"): - runtime.bundled_runtime_path() + assert runtime.bundled_runtime_path() == executable + shutil.rmtree(executable.with_name("linux-x64") / "primary-runtime") + assert runtime.bundled_runtime_path() == executable def test_node_mode_runs_the_deployed_dsh_cli( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - bin_js = tmp_path / "runtime" / "node" / "node_modules" / "@deepseek-ai" / "dsh" / "lib" / "bin.js" + bin_js = tmp_path / "runtime" / "node" / "runtime-bootstrap.mjs" bin_js.parent.mkdir(parents=True) bin_js.touch() monkeypatch.setattr(runtime, "bundled_package_dir", lambda: tmp_path) @@ -303,7 +310,7 @@ def test_windows_console_branch_preserves_real_child_io_and_completion(tmp_path: ("macos-arm64", ("darwin-arm64",)), ("macos-x64", ("darwin-x64",)), ("win-x64", ("win32-x64",)), ("linux-x64", ("linux-x64",)), ("macos-arm64", ()), ]) -def test_runtime_requires_its_platform_office_engine( +def test_runtime_starts_independently_of_office_engine( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, target: str, native_targets: tuple[str, ...], ) -> None: extension = ".exe" if target.startswith("win-") else "" @@ -322,5 +329,102 @@ def test_runtime_requires_its_platform_office_engine( foreign = office / "node_modules/@deepseek-ai" / ("libreoffice-kit-darwin-arm64" if engine.parent.name == "libreoffice-kit-wasm" else "libreoffice-kit-wasm") / "prebuilds.json" foreign.parent.mkdir(parents=True, exist_ok=True) foreign.write_text("{}") - with pytest.raises(FileNotFoundError, match="Office sidecar"): - runtime.bundled_runtime_path() + assert runtime.bundled_runtime_path() == executable + + +@pytest.mark.parametrize("function,resource", [ + (runtime.download_office, "office"), + (runtime.download_primary_runtime, "primary"), +]) +def test_explicit_download_uses_runtime_without_a_harness_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, function, resource: str, +) -> None: + root = tmp_path / "中文缓存" + root.mkdir() + _resource_sidecars(root / "deepseek-harness-sdk-runtime-linux-x64") + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") + script = tmp_path / "download.py" + destination = root / "linux-x64/primary-runtime" if resource == "primary" else root + script.write_text("import json,os,sys\n" + f"assert os.environ['DSH_RUNTIME_DOWNLOAD'] == {resource!r}\n" + + f"sys.stdout.buffer.write(json.dumps({str(destination)!r}, ensure_ascii=False).encode('utf-8'))\n", encoding="utf-8") + monkeypatch.delenv("DSH_HOME", raising=False) + monkeypatch.setattr(runtime, "resolve_bundled_launch_args", lambda: (sys.executable, str(script))) + assert function() == destination + script.write_text("import sys\nsys.stderr.buffer.write('下载失败'.encode('utf-8'))\nsys.exit(2)\n", encoding="utf-8") + with pytest.raises(RuntimeError, match="下载失败"): + function() + + +@pytest.mark.skipif(os.name == "nt", reason="LC_ALL=C is a POSIX locale fixture") +def test_download_decodes_utf8_in_an_ascii_locale(tmp_path: Path) -> None: + script = tmp_path / "download.py" + destination = tmp_path / "中文缓存" + script.write_text("import sys\nsys.stdout.buffer.write(" + json.dumps(str(destination), ensure_ascii=False).encode('utf-8').__repr__() + ")\n", encoding="utf-8") + code = ("import sys,locale,deepseek_harness_runtime as r; " + "assert locale.getpreferredencoding(False).lower() not in ('utf-8','utf8'); " + f"r.resolve_bundled_launch_args=lambda:(sys.executable,{str(script)!a}); " + f"assert str(r.download_office())=={str(destination)!a}") + result = subprocess.run([sys.executable, "-c", code], capture_output=True, encoding="utf-8", env={ + **os.environ, "LC_ALL": "C", "PYTHONUTF8": "0", "PYTHONCOERCECLOCALE": "0", + "PYTHONPATH": str(Path(runtime.__file__).resolve().parents[1]), + }, timeout=30) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("missing", ["primary-runtime/runtime.json", "primary-runtime/dependencies/python/bin/python3", + "primary-runtime/dependencies/node/bin/node", "office-skills/scripts/check_office.py"]) +def test_primary_download_rejects_an_incomplete_payload(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, missing: str) -> None: + _resource_sidecars(tmp_path / "deepseek-harness-sdk-runtime-linux-x64") + root = tmp_path / "linux-x64" + (root / missing).unlink() + monkeypatch.setattr(runtime, "_download", lambda resource: root / "primary-runtime") + monkeypatch.setattr(runtime, "_current_platform_tag", lambda: "linux-x64") + with pytest.raises(FileNotFoundError): + runtime.download_primary_runtime() + + +def test_office_resolution_requires_explicit_download(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + from deepseek_harness_runtime._resources import office_launch_args + + executable = tmp_path / "deepseek-harness-sdk-runtime-linux-x64" + _resource_sidecars(executable) + cache = tmp_path / "cache" + monkeypatch.setenv("DSH_RESOURCE_CACHE", str(cache)) + monkeypatch.delenv("DSH_OFFICE_SIDECAR", raising=False) + with pytest.raises(FileNotFoundError, match=r"download_office\(\)"): + office_launch_args(tmp_path / "linux-x64", "linux-x64") + office = cache / ("a" * 64) / "office" + node = office / "node/bin/node" + cli = office / "node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js" + for path in (node, cli): + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + with pytest.raises(FileNotFoundError, match="incomplete"): + office_launch_args(tmp_path / "linux-x64", "linux-x64") + (office / "complete").write_text("wrong", encoding="utf-8") + with pytest.raises(ValueError, match="identity mismatch"): + office_launch_args(tmp_path / "linux-x64", "linux-x64") + (office / "complete").write_text("a" * 64, encoding="utf-8") + assert office_launch_args(tmp_path / "linux-x64", "linux-x64") == (str(node), str(cli)) + (office / "complete").unlink() + monkeypatch.setenv("DSH_OFFICE_SIDECAR", str(office)) + assert office_launch_args(tmp_path / "linux-x64", "linux-x64") == (str(node), str(cli)) + + +@pytest.mark.parametrize("invalid", ["target", "identity", "office", "pnpm", "skills"]) +def test_download_manifest_rejects_invalid_wheel_resources(tmp_path: Path, invalid: str) -> None: + from deepseek_harness_runtime._resources import validate_downloads + + _resource_sidecars(tmp_path / "deepseek-harness-sdk-runtime-linux-x64") + root = tmp_path / "linux-x64" + path = root / "downloads.json" + manifest = json.loads(path.read_text(encoding="utf-8")) + if invalid == "skills": + (root / "office-skills/scripts/check_office.py").unlink() + with pytest.raises(FileNotFoundError, match="Office skill"): + validate_downloads(root, "linux-x64") + else: + manifest[invalid] = {"target": "win-x64", "identity": "z" * 64, "office": [], "pnpm": []}[invalid] + path.write_text(json.dumps(manifest), encoding="utf-8") + with pytest.raises(ValueError, match="invalid runtime download manifest"): + validate_downloads(root, "linux-x64") diff --git a/scripts/build-exe-for-python-sdk-office.spec.ts b/scripts/build-exe-for-python-sdk-office.spec.ts index d664a0ffbc..64c61687ee 100644 --- a/scripts/build-exe-for-python-sdk-office.spec.ts +++ b/scripts/build-exe-for-python-sdk-office.spec.ts @@ -1,8 +1,12 @@ -import { chmod, mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' +import { join } from 'node:path' import { afterEach, expect, it } from 'vitest' -import { copyOfficeSidecar } from './build-exe-for-python-sdk-office.ts' +import { createHash } from 'node:crypto' +import { c as tar } from 'tar' +import { downloadOfficeSidecar, runtimeNpmArchive } from './build-exe-for-python-sdk-office.ts' + +import { officePackageDirectories } from './libreoffice-packages.mjs' const temporaryDirectories: string[] = [] @@ -24,33 +28,8 @@ async function fixture() { return { root, staging, destination, packageAt } } -it('copies package-owned data, licenses, helper permissions, and nested dependencies', async () => { - const { staging, destination, packageAt } = await fixture() - const entry = await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' }, dependencies: { decoder: '1' } }) - const engine = await packageAt('@deepseek-ai/libreoffice-kit-wasm') - const decoder = await packageAt('decoder', { dependencies: { codec: '1' } }, entry) - await packageAt('codec', {}, decoder) - await packageAt('codec', { version: '2.0.0' }) - const assets = ['assets/soffice.data', 'prebuilds.json', 'licenses/LICENSE', 'bin/helper'] - for (const name of assets) { - const path = join(engine, name) - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, name) - } - await chmod(join(engine, 'bin/helper'), 0o755) - - const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }) - - expect(packages).toHaveLength(4) - expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/assets/soffice.data'), 'utf8')).toBe('assets/soffice.data') - expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/licenses/LICENSE'), 'utf8')).toBe('licenses/LICENSE') - expect(await readFile(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit/node_modules/decoder/node_modules/codec/package.json'), 'utf8')).toContain('codec') - await expect(stat(join(destination, 'node_modules/codec'))).rejects.toMatchObject({ code: 'ENOENT' }) - if (process.platform !== 'win32') expect((await stat(join(destination, 'node_modules/@deepseek-ai/libreoffice-kit-wasm/bin/helper'))).mode & 0o111).toBe(0o111) -}) - -it('copies installed target optionals and leaves other platforms and absent optionals out', async () => { - const { staging, destination, packageAt } = await fixture() +it('locates installed target optionals and leaves other platforms and absent optionals out', async () => { + const { staging, packageAt } = await fixture() await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { native: '1', foreign: '1', absent: '1' }, }) @@ -58,84 +37,112 @@ it('copies installed target optionals and leaves other platforms and absent opti await packageAt('native', { os: ['linux'], cpu: ['x64'] }) await packageAt('foreign', { os: ['darwin'], cpu: ['arm64'] }) - const packages = await copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' }) + const packages = await officePackageDirectories(staging, { platform: 'linux', arch: 'x64' }) - expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([ + expect(packages.map(path => path.slice(staging.length + 1).replaceAll('\\', '/'))).toEqual([ 'node_modules/@deepseek-ai/libreoffice-kit', 'node_modules/@deepseek-ai/libreoffice-kit-wasm', 'node_modules/native', ]) }) -it('rejects a missing required dependency before producing a sidecar', async () => { - const { staging, destination, packageAt } = await fixture() +it('rejects a missing required dependency in the deployed closure', async () => { + const { staging, packageAt } = await fixture() await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'dsh-missing-office-fixture': '1' } }) - await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + await expect(officePackageDirectories(staging, { platform: 'linux', arch: 'x64' })) .rejects.toThrow('dsh-missing-office-fixture required by @deepseek-ai/libreoffice-kit is missing') - await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) }) it('rejects an incomplete installed optional instead of omitting it', async () => { - const { staging, destination, packageAt } = await fixture() + const { staging, packageAt } = await fixture() await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { broken: '1' } }) await mkdir(join(staging, 'node_modules/broken'), { recursive: true }) - await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + await expect(officePackageDirectories(staging, { platform: 'linux', arch: 'x64' })) .rejects.toMatchObject({ code: 'ENOENT' }) }) it('rejects an ancestor dependency outside the deployed closure', async () => { - const { root, staging, destination, packageAt } = await fixture() + const { root, staging, packageAt } = await fixture() await packageAt('@deepseek-ai/libreoffice-kit', { dependencies: { 'ancestor-office-fixture': '1' } }) await packageAt('ancestor-office-fixture', {}, root) - await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + await expect(officePackageDirectories(staging, { platform: 'linux', arch: 'x64' })) .rejects.toThrow('outside the deployed closure') }) -it('requires the declared WASM engine inside the deployed closure', async () => { - const { root, staging, destination, packageAt } = await fixture() - await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } }) +it.each([true, false])('requires a staged WASM engine when declared=%s even when an ancestor has one', async (declared) => { + const { root, staging, packageAt } = await fixture() + await packageAt('@deepseek-ai/libreoffice-kit', declared ? { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1' } } : {}) await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) - await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) + await expect(officePackageDirectories(staging, { platform: 'linux', arch: 'x64' })) .rejects.toThrow('Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.') - await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) }) it.each([ ['darwin', 'arm64', 'darwin-arm64'], ['darwin', 'x64', 'darwin-x64'], ['win32', 'arm64', 'win32-arm64'], ['win32', 'x64', 'win32-x64'], ['linux', 'x64', 'linux-x64'], ['linux', 'arm64', 'wasm'], ['freebsd', 'x64', 'wasm'], -])('copies only the %s/%s engine even when other engines are staged', async (platform, arch, selected) => { - const { staging, destination, packageAt } = await fixture() +])('locates only the %s/%s engine even when other engines are staged', async (platform, arch, selected) => { + const { staging, packageAt } = await fixture() const targets = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64', 'linux-x64', 'wasm'] const names = targets.map(target => `@deepseek-ai/libreoffice-kit-${target}`) await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: Object.fromEntries(names.map(name => [name, '0.0.1'])) }) for (const name of names) await packageAt(name) const expected = `@deepseek-ai/libreoffice-kit-${selected}` - const packages = await copyOfficeSidecar(staging, destination, { platform, arch }) - expect(packages.map(path => path.replaceAll('\\', '/'))).toEqual([ + const packages = await officePackageDirectories(staging, { platform, arch }) + expect(packages.map(path => path.slice(staging.length + 1).replaceAll('\\', '/'))).toEqual([ 'node_modules/@deepseek-ai/libreoffice-kit', `node_modules/${expected}`, ]) }) it.each(['darwin', 'win32', 'linux'])('%s requires its native package even when WASM is staged', async (platform) => { - const { staging, destination, packageAt } = await fixture() + const { staging, packageAt } = await fixture() await packageAt('@deepseek-ai/libreoffice-kit', { optionalDependencies: { '@deepseek-ai/libreoffice-kit-wasm': '0.0.1', [`@deepseek-ai/libreoffice-kit-${platform}-arm64`]: '0.0.1', } }) await packageAt('@deepseek-ai/libreoffice-kit-wasm') - await expect(copyOfficeSidecar(staging, destination, { platform, arch: 'arm64' })) + await expect(officePackageDirectories(staging, { platform, arch: 'arm64' })) .rejects.toThrow(`Office engine @deepseek-ai/libreoffice-kit-${platform}-arm64 required for ${platform}/arm64 is missing.`) - await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) }) -it('requires a staged Linux WASM engine even when an ancestor has one', async () => { - const { root, staging, destination, packageAt } = await fixture() - await packageAt('@deepseek-ai/libreoffice-kit') - await packageAt('@deepseek-ai/libreoffice-kit-wasm', {}, root) - await expect(copyOfficeSidecar(staging, destination, { platform: 'linux', arch: 'x64' })) - .rejects.toThrow('Office engine @deepseek-ai/libreoffice-kit-wasm required for linux/x64 is missing.') - await expect(stat(destination)).rejects.toMatchObject({ code: 'ENOENT' }) +it('downloads pinned npm packages into the sidecar without resolving or executing package scripts', async () => { + const { root, destination } = await fixture() + const source = join(root, 'package') + await mkdir(source) + await writeFile(join(source, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { install: 'must-not-run' } })) + await writeFile(join(source, 'worker.js'), 'worker') + const archive = join(root, 'package.tgz') + await tar({ file: archive, cwd: root, gzip: true }, ['package']) + const bytes = await readFile(archive) + const checksum = createHash('sha512').update(bytes).digest() + const cache = join(root, 'archives') + await mkdir(cache) + await writeFile(join(cache, checksum.toString('hex')), bytes) + const artifact = { name: 'fixture', version: '1.0.0', directory: 'node_modules/fixture', + url: 'https://unused.invalid/fixture.tgz', integrity: `sha512-${checksum.toString('base64')}` } + await downloadOfficeSidecar([artifact], destination, cache) + expect(await readFile(join(destination, 'node_modules/fixture/worker.js'), 'utf8')).toBe('worker') + await expect(downloadOfficeSidecar([{ ...artifact, directory: 'node_modules/../../outside' }], destination, cache)) + .rejects.toThrow('invalid package directory') + await expect(downloadOfficeSidecar([{ ...artifact, version: '2.0.0' }], destination, cache)) + .rejects.toThrow('package identity mismatch') + await writeFile(join(cache, checksum.toString('hex')), 'corrupted archive') + await expect(downloadOfficeSidecar([artifact], destination, cache)).rejects.toThrow('checksum mismatch') +}) + + +it('takes npm integrity from the workspace lock without querying registry metadata', async () => { + const { root } = await fixture() + const lockfile = join(root, 'pnpm-lock.yaml') + const integrity = `sha512-${Buffer.alloc(64, 7).toString('base64')}` + await writeFile(lockfile, JSON.stringify({ packages: { '@scope/fixture@1.2.3': { resolution: { integrity } } } })) + expect(await runtimeNpmArchive('@scope/fixture', '1.2.3', lockfile)).toEqual({ + name: '@scope/fixture', version: '1.2.3', integrity, + url: 'https://registry.npmjs.org/@scope/fixture/-/fixture-1.2.3.tgz', + }) + await expect(runtimeNpmArchive('@scope/fixture', '1.2.4', lockfile)).rejects.toThrow('missing SHA-512 integrity') + await writeFile(lockfile, JSON.stringify({ packages: { '@scope/fixture@1.2.3': { resolution: { integrity: 'sha256-invalid' } } } })) + await expect(runtimeNpmArchive('@scope/fixture', '1.2.3', lockfile)).rejects.toThrow('missing SHA-512 integrity') }) diff --git a/scripts/build-exe-for-python-sdk-office.ts b/scripts/build-exe-for-python-sdk-office.ts index bc482c9c1f..f1ffe4bd63 100644 --- a/scripts/build-exe-for-python-sdk-office.ts +++ b/scripts/build-exe-for-python-sdk-office.ts @@ -1,6 +1,8 @@ /** Keep LibreOffice workers, prebuilt engines, and their dependencies on the real filesystem. */ -import { cp, mkdir, rm } from 'node:fs/promises' -import { join, relative, sep } from 'node:path' +import { readFile } from 'node:fs/promises' +import { isAbsolute, join, relative, sep } from 'node:path' +import { downloadRuntimeNpmPackage, type RuntimeNpmArchive } from './primary-runtime/prepare.ts' +import { load as parseYaml } from 'js-yaml' import { officePackageDirectories } from './libreoffice-packages.mjs' /** pkg applies these exclusions to dependency `files` as well as root asset globs. */ @@ -9,29 +11,53 @@ export const OFFICE_ASSET_IGNORES = [ '**/node_modules/@deepseek-ai/libreoffice-kit-*/**', ] +/** One npm package at its existing sidecar dependency location. */ +export interface OfficePackageArchive extends RuntimeNpmArchive { + /** Relative package directory inside the sidecar. */ + readonly directory: string +} + /** - * Copy the installed Office dependency tree without changing package contents or executable modes. - * Harness sidecars require the kit's declared target native engine, or WASM for other targets. - * Missing target engines fail with their package name; missing required dependencies or paths outside the deployed closure also fail. - * @param staging - Symlink-free deployed Node closure. - * @param destination - Target-specific Office directory beside the executable; replaced when present. - * @param target - Node platform and CPU of the executable. - * @returns Relative package directories included in the sidecar. + * Resolve one installed npm version against its reviewed workspace lock integrity. + * @param name - Published package name. + * @param version - Exact installed version. + * @param lockfile - Workspace lock recording the exact published archive. + * @returns Locked npm tarball identity; rejects versions without SHA-512 integrity. */ -export async function copyOfficeSidecar( - staging: string, - destination: string, - target: { platform: string; arch: string }, -): Promise { +export async function runtimeNpmArchive(name: string, version: string, lockfile = join(import.meta.dirname, '../pnpm-lock.yaml')): Promise { + const lock = parseYaml(await readFile(lockfile, 'utf8')) as { packages?: Record } + const integrity = lock.packages?.[`${name}@${version}`]?.resolution?.integrity + if (typeof integrity !== 'string' || !/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(integrity)) { + throw new Error(`runtime npm lock: missing SHA-512 integrity for ${name}@${version}`) + } + return { name, version, url: `https://registry.npmjs.org/${name}/-/${name.split('/').at(-1)}-${version}.tgz`, integrity } +} + +/** + * Lock the installed Office closure to published npm tarballs, preserving nested dependency locations. + * @param staging - Complete installed Node closure. + * @param target - Sidecar platform and CPU. + * @returns Download records for the selected engine and all dependencies. + */ +export async function officeSidecarArchives(staging: string, target: { platform: string; arch: string }): Promise { const directories = await officePackageDirectories(staging, target) - await rm(destination, { recursive: true, force: true }) - await mkdir(destination, { recursive: true }) - for (const source of directories) { - const nestedModules = join(source, 'node_modules') - await cp(source, join(destination, relative(staging, source)), { - recursive: true, - filter: path => path !== nestedModules && !path.startsWith(nestedModules + sep), - }) + return Promise.all(directories.map(async (directory) => { + const manifest = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as { name: string; version: string } + return { ...await runtimeNpmArchive(manifest.name, manifest.version), directory: relative(staging, directory).split(sep).join('/') } + })) +} + +/** + * Download the complete pinned Office sidecar without npm or package lifecycle scripts. + * @param packages - Build-locked npm packages and dependency locations. + * @param destination - Empty sidecar directory. + * @param cache - Verified archive cache. + * @returns Resolves after all packages are unpacked and checked. + */ +export async function downloadOfficeSidecar(packages: readonly OfficePackageArchive[], destination: string, cache: string): Promise { + for (const artifact of packages) { + if (isAbsolute(artifact.directory) || artifact.directory.split(/[\\/]/u).some(part => part === '..') + || !artifact.directory.startsWith('node_modules/')) throw new Error(`Office download: invalid package directory ${artifact.directory}`) + await downloadRuntimeNpmPackage(artifact, join(destination, artifact.directory), cache) } - return directories.map(directory => relative(staging, directory)) } diff --git a/scripts/build-exe-for-python-sdk.spec.ts b/scripts/build-exe-for-python-sdk.spec.ts index 3e02d7e9b1..0ce2206f1e 100644 --- a/scripts/build-exe-for-python-sdk.spec.ts +++ b/scripts/build-exe-for-python-sdk.spec.ts @@ -175,7 +175,8 @@ describe('Python runtime executable builder CLI', () => { expect(result.status).toBe(0) expect(result.stdout).toContain('exec pkg') expect(result.stdout).toContain('--sea --targets node24-macos-x64') - expect(result.stdout).toContain('prepare Python and Office skills for mac-x64') + expect(result.stdout).toContain('keep only node-pty prebuilds darwin-x64') + expect(result.stdout).toContain('lock optional resource downloads and copy Office skills') expect(result.stdout).toContain(join(root, 'dist-exe', 'macos-x64')) }) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 1ac823d28e..d61453fa76 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -6,17 +6,20 @@ * runtime imports that pkg cannot discover statically. */ +import { createHash } from 'node:crypto' +import { build as bundle } from 'tsdown' import { spawn } from 'node:child_process' import { pnpmInvocation, restoreLegacyHoists } from './executable-packaging.ts' import { existsSync, statSync } from 'node:fs' -import { chmod, copyFile, cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { chmod, copyFile, cp, glob, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' +import { pathToFileURL } from 'node:url' import { resolveLinuxNodePtyAddon, resolveWindowsNodePtyAddons } from './executable-native-pty.ts' -import { copyOfficeSidecar, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts' +import { officeSidecarArchives, runtimeNpmArchive, OFFICE_ASSET_IGNORES } from './build-exe-for-python-sdk-office.ts' import { deduplicateStagedWorkspacePackages, materializeStagedLinks } from './build-exe-for-python-sdk-staging.ts' -import { preparePrimaryRuntime, smokePrimaryRuntime, type PrimaryRuntimeTarget } from './primary-runtime/prepare.ts' +import { prepareOfficeSkillAssets } from './primary-runtime/prepare.ts' +import primaryLock from './primary-runtime/lock.json' with { type: 'json' } const root = resolve(import.meta.dirname, '..') @@ -45,6 +48,7 @@ const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] */ const ASSET_GLOBS = [ 'package.json', + '*.mjs', 'node_modules/**/*.js', 'node_modules/**/*.cjs', 'node_modules/**/*.mjs', @@ -301,6 +305,9 @@ class SingleExeBuild { for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) } else { await Promise.all(DEPLOY_ONLY_DOCS.map(name => rm(join(this.staging, name), { force: true }))) + for await (const file of glob('node_modules/**/{README*.md,CHANGELOG*.md,HISTORY*.md,*.map,*.d.ts,*.d.mts,*.d.cts}', { + cwd: this.staging, exclude: ['node_modules/**/assets/**'], + })) await rm(join(this.staging, file), { force: true }) } } @@ -331,7 +338,14 @@ class SingleExeBuild { /** Add the executable entry and pkg assets to the staged manifest. */ async injectPkgConfig(): Promise { - const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS, ignore: OFFICE_ASSET_IGNORES } } + const sourceModules = join(root, 'node_modules').replaceAll('\\', '/') + const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS, ignore: [ + ...OFFICE_ASSET_IGNORES, + `${sourceModules}/**`, `${sourceModules}/.pnpm/**`, + ...['packages', 'apps', 'vendor', 'native', 'scripts'].map(directory => `${join(root, directory).replaceAll('\\', '/')}/**`), + `${root.replaceAll('\\', '/')}/tsconfig*.json`, + '**/*.map', '**/*.d.ts', '**/*.d.mts', '**/*.d.cts', + ] } } const manifestPath = join(this.staging, 'package.json') if (this.cli.dryRun) { console.log(`build-exe-for-python-sdk: [dry-run] patch ${manifestPath} with ${JSON.stringify(patch)}`) @@ -343,8 +357,28 @@ class SingleExeBuild { if (!existsSync(join(this.staging, ENTRY_BIN))) { throw new Error(`build-exe-for-python-sdk: staged bootstrap ${join(this.staging, ENTRY_BIN)} is missing.`) } + const helpers = [ + { name: 'primary-runtime', source: 'scripts/primary-runtime/prepare.ts', exports: 'downloadNodeRuntime, preparePrimaryRuntime' }, + { name: 'office-sidecar', source: 'scripts/build-exe-for-python-sdk-office.ts', exports: 'downloadOfficeSidecar' }, + ] + const entries: Record = {} + for (const helper of helpers) { + const entry = join(this.staging, `.${helper.name}-entry.ts`) + entries[helper.name] = entry + await writeFile(entry, `export { ${helper.exports} } from ${JSON.stringify(join(root, helper.source).replaceAll('\\', '/'))}\n`) + } + try { + await bundle({ config: false, tsconfig: false, inputOptions: { tsconfig: false }, target: 'es2024', entry: entries, + outDir: this.staging, clean: false, format: 'esm', platform: 'node', dts: false, + deps: { neverBundle: [/^@deepseek-ai\//u], alwaysBundle: [/.*/u] }, shims: true, outExtensions: () => ({ js: '.mjs' }), + define: { 'import.meta.main': 'false' } }) + } finally { + for (const entry of Object.values(entries)) await rm(entry, { force: true }) + } const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record await writeFile(manifestPath, `${JSON.stringify({ ...manifest, ...patch }, null, 2)}\n`) + await this.run('resource helper imports', process.execPath, ['--input-type=module', '--eval', + ['primary-runtime.mjs', 'office-sidecar.mjs'].map(file => `await import(${JSON.stringify(pathToFileURL(join(this.staging, file)).href)})`).join('\n')]) console.log(`build-exe-for-python-sdk: injected pkg config into ${manifestPath}`) } @@ -357,6 +391,7 @@ class SingleExeBuild { const productBase = join(this.outDir, `${OUTPUT_BASENAME}-${target.platform}-${target.arch}`) const product = target.platform === 'win' ? `${productBase}.exe` : productBase await this.prepareNativePty(target) + const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform if (!this.cli.dryRun) await mkdir(this.outDir, { recursive: true }) await this.runPnpm(`pkg ${target.spec}`, [ 'exec', @@ -371,26 +406,22 @@ class SingleExeBuild { if (!this.cli.dryRun && !existsSync(product)) { throw new Error(`build-exe-for-python-sdk: product ${product} is missing after the pkg run; inspect ${this.outDir}.`) } - const office = `${productBase}-office` - if (this.cli.dryRun) { - console.log(`build-exe-for-python-sdk: [dry-run] copy Office dependency closure from ${this.staging} to ${office}`) - } else { - const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform - const packages = await copyOfficeSidecar(this.staging, office, { platform, arch: target.arch }) - console.log(`build-exe-for-python-sdk: copied ${packages.length} Office packages to ${office}`) - } const ripgrep = await this.copyRipgrepSidecar(target, product) const resources = join(this.outDir, `${target.platform}-${target.arch}`) - const runtimeTarget = `${target.platform === 'macos' ? 'mac' : target.platform}-${target.arch}` as PrimaryRuntimeTarget if (this.cli.dryRun) { - console.log(`build-exe-for-python-sdk: [dry-run] prepare Python and Office skills for ${runtimeTarget} in ${resources}`) + console.log(`build-exe-for-python-sdk: [dry-run] lock optional resource downloads and copy Office skills to ${resources}`) } else { - const { version } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version: string } - await preparePrimaryRuntime({ target: runtimeTarget, output: resources, - cache: join(tmpdir(), 'dsh-primary-runtime-downloads'), version }) - smokePrimaryRuntime(join(resources, 'primary-runtime')) + await rm(resources, { recursive: true, force: true }) + await mkdir(resources, { recursive: true }) + const { version, packageManager } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { version: string; packageManager: string } + const office = await officeSidecarArchives(this.staging, { platform, arch: target.arch }) + const pnpm = await runtimeNpmArchive('pnpm', packageManager.replace('pnpm@', '')) + const inputs = { version, target: `${target.platform === 'macos' ? 'mac' : target.platform}-${target.arch}`, office, pnpm } + const identity = createHash('sha256').update(JSON.stringify({ ...inputs, primaryLock })).digest('hex') + await writeFile(join(resources, 'downloads.json'), JSON.stringify({ ...inputs, identity }, undefined, 2) + '\n') + await prepareOfficeSkillAssets(join(this.staging, 'node_modules/@deepseek-ai/dsh-skill-office/assets'), join(resources, 'office-skills')) } - if (target.platform !== 'macos') return [product, ripgrep, office, resources] + if (target.platform !== 'macos') return [product, ripgrep, resources] const spawnHelper = `${product}-spawn-helper` const source = join(this.staging, 'node_modules', 'node-pty', 'prebuilds', `darwin-${target.arch}`, 'spawn-helper') if (this.cli.dryRun) { @@ -399,7 +430,7 @@ class SingleExeBuild { await copyFile(source, spawnHelper) await chmod(spawnHelper, 0o755) } - return [product, ripgrep, spawnHelper, office, resources] + return [product, ripgrep, spawnHelper, resources] } /** Copy the target ripgrep binary beside the executable so Node can spawn it outside pkg's virtual filesystem. */ @@ -446,6 +477,17 @@ class SingleExeBuild { 'node_modules', 'node-pty', ) + const platform = target.platform === 'macos' ? 'darwin' : target.platform === 'win' ? 'win32' : target.platform + const selected = `${platform}-${target.arch}` + const prebuilds = join(this.staging, 'node_modules/node-pty/prebuilds') + if (this.cli.dryRun) console.log(`build-exe-for-python-sdk: [dry-run] keep only node-pty prebuilds ${selected}`) + else { + const source = join(packageDirectory, 'prebuilds', selected) + if (existsSync(source)) await cp(source, join(prebuilds, selected), { recursive: true }) + for (const entry of await readdir(prebuilds, { withFileTypes: true })) { + if (entry.isDirectory() && entry.name !== selected) await rm(join(prebuilds, entry.name), { recursive: true, force: true }) + } + } if (target.platform === 'win') { if (target.arch !== 'x64') { throw new Error('build-exe-for-python-sdk: Windows supports x64 only.') diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index 2918feb380..82c9f9833a 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -56,11 +56,6 @@ def runtime_filenames(executable_name: str) -> tuple[str, ...]: return (*names, f"{executable_name}-spawn-helper") if "-macos-" in executable_name else names -def office_sidecar_name(executable_name: str) -> str: - """Return the complete Office dependency directory for one executable.""" - return f"{executable_name.removesuffix('.exe')}-office" - - def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--package", choices=("sdk", "runtime"), required=True) @@ -229,54 +224,18 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_ source_directory = executable.parent for filename in runtime_filenames(executable_name): shutil.copy2(source_directory / filename, runtime_dir / filename) - office = office_sidecar_name(executable_name) - shutil.copytree(source_directory / office, runtime_dir / office) resources = executable_name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe") shutil.copytree(source_directory / resources, runtime_dir / resources) -def verify_office_payload(archive: zipfile.ZipFile, office_modules: str, platform_tag: str) -> None: - """Check packaged engine assets and native helper executable permissions.""" - names = set(archive.namelist()) - adapter = f"{office_modules}/@deepseek-ai/libreoffice-kit/package.json" - if adapter not in names: - raise RuntimeError("Office dependency is missing: libreoffice-kit") - engines = f"{office_modules}/@deepseek-ai" - target = next(name for name, value in PLATFORMS.items() if value[0] == platform_tag) - native_target = target.replace("win-", "win32-").replace("macos-", "darwin-") - declared = json.loads(archive.read(adapter)).get("optionalDependencies", {}) - selected = native_target if f"@deepseek-ai/libreoffice-kit-{native_target}" in declared else "wasm" - for required in (f"libreoffice-kit-{selected}/prebuilds.json",): - if f"{engines}/{required}" not in names: - raise RuntimeError(f"Office dependency is missing: {required}") - manifests = ( - name for name in names - if name.startswith(f"{engines}/libreoffice-kit-") - and name.endswith("/prebuilds.json") - and name.count("/") == engines.count("/") + 2 - ) - for manifest_path in manifests: - if manifest_path != f"{engines}/libreoffice-kit-{selected}/prebuilds.json": - raise RuntimeError(f"Unexpected Office engine for {platform_tag}: {manifest_path}") - manifest = json.loads(archive.read(manifest_path)) - engine = manifest["engine"] - native = engine["kind"] == "native" - fields = ("executable",) if native else ("loader", "wasm", "data", "metadata") - for field in fields: - asset = f"{manifest_path.rsplit('/', 1)[0]}/{engine[field]}" - if asset not in names: - raise RuntimeError(f"Office engine asset is missing: {asset}") - mode = archive.getinfo(asset).external_attr >> 16 - if native and platform_tag != "win_amd64" and mode & stat.S_IXUSR == 0: - raise RuntimeError(f"Office helper lost its executable bit: {asset}") - - def verify_wheel( wheel: Path, package: str, version: str, platform: tuple[str, str] | None, ) -> None: + if wheel.stat().st_size > 100_000_000: + raise RuntimeError(f"{wheel} is {wheel.stat().st_size} bytes and exceeds the PyPI single-file limit of 100000000 bytes") expected_tag = "py3-none-any" if platform is None else f"py3-none-{platform[0]}" expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION dist_info = f"{expected_distribution.replace('-', '_')}-{version}.dist-info" @@ -308,15 +267,12 @@ def verify_wheel( ] if package == "runtime": assert platform is not None - office = office_sidecar_name(platform[1]) resources = platform[1].removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe") - expected_files = sorted((*runtime_filenames(platform[1]), office, resources)) + expected_files = sorted((*runtime_filenames(platform[1]), resources)) found_files = sorted({name.split("/runtime/", 1)[1].split("/", 1)[0] for name in runtime_payload}) if found_files != expected_files: raise RuntimeError(f"{wheel} runtime payload must be {expected_files}, found {found_files}") - office_modules = f"deepseek_harness_runtime/runtime/{office}/node_modules" - verify_office_payload(archive, office_modules, platform[0]) - validate = runpy.run_path(str(ROOT / "python/sdk-runtime/src/deepseek_harness_runtime/_resources.py"))["validate_resources"] + validate = runpy.run_path(str(ROOT / "python/sdk-runtime/src/deepseek_harness_runtime/_resources.py"))["validate_downloads"] target = next(name for name, value in PLATFORMS.items() if value == platform) validate(zipfile.Path(archive, f"deepseek_harness_runtime/runtime/{resources}/"), target) for runtime_file in runtime_payload: diff --git a/scripts/fixtures/python-sdk-office.mjs b/scripts/fixtures/python-sdk-office.mjs index 1f11e2cdbf..1b11a089f4 100644 --- a/scripts/fixtures/python-sdk-office.mjs +++ b/scripts/fixtures/python-sdk-office.mjs @@ -15,7 +15,7 @@ export async function apply(ctx, config) { if (json === undefined) throw new Error('Office skill did not supply CLI paths') const { libreofficeKit: { node, cli } } = JSON.parse(json) const options = { env: { ...process.env, PATH: '' }, timeout: 120_000 } - const capabilities = await promisify(execFile)(node, [cli, 'capabilities'], options) + const capabilities = await promisify(execFile)(node, [cli, 'capabilities', '--json'], options) await promisify(execFile)(node, [cli, 'convert', '--input', config.input, '--output', config.output + '.cli.pdf'], options) const pdf = await readFile(config.output + '.cli.pdf') if (pdf.subarray(0, 5).toString() !== '%PDF-') throw new Error('Skill CLI did not create a PDF') diff --git a/scripts/primary-runtime/prepare.spec.ts b/scripts/primary-runtime/prepare.spec.ts index 6100bc2b5b..11b5b7a6a8 100644 --- a/scripts/primary-runtime/prepare.spec.ts +++ b/scripts/primary-runtime/prepare.spec.ts @@ -1,6 +1,6 @@ import { readFileSync } from 'node:fs' import { createHash } from 'node:crypto' -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { zipSync } from 'fflate' @@ -61,7 +61,11 @@ it('extracts a hash-verified cached library without a Python installer or networ const hash = createHash('sha256').update(libraryWheel).digest('hex') const archive = join(root, hash) await writeFile(archive, libraryWheel) + const original = await stat(archive) expect(await downloadPrimaryRuntimeAsset('https://unused.invalid/library.whl', hash, root)).toBe(archive) + const reused = await stat(archive) + expect(reused.ino).toBe(original.ino) + expect(reused.mtimeMs).toBe(original.mtimeMs) await unpackPrimaryRuntimeWheel(archive, join(root, 'site-packages')) expect(await readFile(join(root, 'site-packages/sample.py'), 'utf8')).toBe('sample = 42\n') await writeFile(archive, 'corrupt archive') diff --git a/scripts/primary-runtime/prepare.ts b/scripts/primary-runtime/prepare.ts index a156a7564a..b42158bb7e 100644 --- a/scripts/primary-runtime/prepare.ts +++ b/scripts/primary-runtime/prepare.ts @@ -1,8 +1,8 @@ /** Prepare pinned, relocatable script interpreters without installing into the build host. */ import { execFileSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createHash, randomUUID } from 'node:crypto' +import { cpSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { cp } from 'node:fs/promises' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' @@ -10,30 +10,100 @@ import { dirname, join, resolve } from 'node:path' import { parseArgs } from 'node:util' import extractZip from 'extract-zip' import { x as extractTar } from 'tar' -import { parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '../../packages/skill/tool-workspace-dependencies/src/index.ts' +import { parsePrimaryRuntime, workspaceDependencyPaths, type PrimaryRuntimeManifest } from '@deepseek-ai/dsh-tool-workspace-dependencies' import lock from './lock.json' with { type: 'json' } /** * Download or reuse an archive only when its bytes match the release lock. * @param url - Locked archive URL. - * @param sha256 - Expected SHA-256 digest. + * @param sha256 - Expected hexadecimal digest for the selected algorithm. * @param cache - Download cache directory. + * @param algorithm - Digest algorithm; npm archives use SHA-512. * @returns Verified local archive path. */ -export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string): Promise { +export async function downloadPrimaryRuntimeAsset(url: string, sha256: string, cache: string, algorithm: 'sha256' | 'sha512' = 'sha256'): Promise { + mkdirSync(cache, { recursive: true }) const destination = join(cache, sha256) let bytes: Buffer + let cached = true try { bytes = readFileSync(destination) } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error - const response = await fetch(url) + cached = false + const timeout = Number(process.env.DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS ?? '300000') + if (!Number.isSafeInteger(timeout) || timeout < 1 || timeout > 2_147_483_647) throw new Error('DSH_RESOURCE_DOWNLOAD_TIMEOUT_MS must be an integer from 1 to 2147483647') + const response = await fetch(url, { signal: AbortSignal.timeout(timeout) }) if (!response.ok) throw new Error(`primary runtime download: ${String(response.status)} ${url}`) bytes = Buffer.from(await response.arrayBuffer()) } - if (createHash('sha256').update(bytes).digest('hex') !== sha256) throw new Error(`primary runtime download: checksum mismatch for ${url}`) - writeFileSync(destination, bytes) + if (createHash(algorithm).update(bytes).digest('hex') !== sha256) { + rmSync(destination, { force: true }) + throw new Error(`primary runtime download: checksum mismatch for ${url}`) + } + if (cached) return destination + const temporary = join(cache, `.${randomUUID()}`) + try { + writeFileSync(temporary, bytes) + renameSync(temporary, destination) + } finally { + rmSync(temporary, { force: true }) + } return destination } +/** Pinned npm tarball, without dependency resolution or lifecycle scripts. */ +export interface RuntimeNpmArchive { + /** Exact published package name. */ + readonly name: string + /** Exact published version. */ + readonly version: string + /** Registry tarball URL. */ + readonly url: string + /** npm SHA-512 integrity. */ + readonly integrity: string +} + +/** + * Download and unpack one locked npm package without running its scripts. + * @param artifact - Published tarball identity and integrity. + * @param destination - Empty package directory. + * @param cache - Verified archive cache. + * @returns Resolves after package identity validation. + */ +export async function downloadRuntimeNpmPackage(artifact: RuntimeNpmArchive, destination: string, cache: string): Promise { + if (!/^sha512-[A-Za-z0-9+/]+={0,2}$/u.test(artifact.integrity)) throw new Error(`runtime download: invalid integrity for ${artifact.name}`) + const checksum = Buffer.from(artifact.integrity.slice(7), 'base64').toString('hex') + const archive = await downloadPrimaryRuntimeAsset(artifact.url, checksum, cache, 'sha512') + mkdirSync(destination, { recursive: true }) + await extractTar({ file: archive, cwd: destination, strip: 1 }) + const installed = JSON.parse(readFileSync(join(destination, 'package.json'), 'utf8')) as { name?: string; version?: string } + if (installed.name !== artifact.name || installed.version !== artifact.version) throw new Error(`runtime download: package identity mismatch for ${artifact.name}`) +} + +/** + * Prepare the locked standalone Node independently of Python and pnpm. + * @param target - Target interpreter platform. + * @param destination - Directory receiving bin/node and LICENSE. + * @param cache - Verified archive cache. + * @returns Resolves after extracting the interpreter. + */ +export async function downloadNodeRuntime(target: PrimaryRuntimeTarget, destination: string, cache: string): Promise { + const artifact = lock.targets[target] + const filename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}` + const archive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${filename}`, artifact.nodeSha256, cache) + const staging = mkdtempSync(join(tmpdir(), 'dsh-node-')) + try { + if (target === 'win-x64') await extractZip(archive, { dir: staging }) + else await extractTar({ file: archive, cwd: staging }) + const source = join(staging, filename.replace(/\.(?:zip|tar\.gz)$/u, '')) + mkdirSync(join(destination, 'bin'), { recursive: true }) + cpSync(join(source, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])), + join(destination, 'bin', target === 'win-x64' ? 'node.exe' : 'node')) + cpSync(join(source, 'LICENSE'), join(destination, 'LICENSE')) + } finally { + rmSync(staging, { recursive: true, force: true }) + } +} + async function pythonArchive(target: keyof typeof lock.targets, cache: string): Promise { const artifact = lock.targets[target] const filename = `cpython-${lock.pythonVersion}+${lock.pythonRelease}-${artifact.pythonTarget}-install_only_stripped.tar.gz` @@ -91,7 +161,7 @@ export async function prepareOfficeSkillAssets(source: string, destination: stri /** A locked interpreter and wheel target. */ export type PrimaryRuntimeTarget = keyof typeof lock.targets -/** Build-only inputs shared by Desktop and SDK carriers. */ +/** Resource preparation inputs shared by builds and explicit SDK downloads. */ export interface PreparePrimaryRuntimeOptions { /** Target whose archives and wheels are downloaded. */ readonly target: PrimaryRuntimeTarget @@ -101,6 +171,10 @@ export interface PreparePrimaryRuntimeOptions { readonly cache: string /** Carrier release recorded in the legacy desktopVersion manifest field. */ readonly version: string + /** Locked npm pnpm tarball for installed carriers; build hosts otherwise copy their installed pnpm. */ + readonly pnpmArchive?: RuntimeNpmArchive + /** Ordinary filesystem Office assets; build hosts otherwise resolve the workspace package. */ + readonly skillSource?: string /** Omit Node.js and pnpm for carriers providing only Python. */ readonly pythonOnly?: boolean } @@ -113,7 +187,6 @@ export interface PreparePrimaryRuntimeOptions { export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOptions): Promise { const { target } = options const paths = { runtime: resolve(options.output), downloads: resolve(options.cache) } - const artifact = lock.targets[target] mkdirSync(paths.runtime, { recursive: true }) mkdirSync(paths.downloads, { recursive: true }) const staging = mkdtempSync(join(tmpdir(), 'dsh-primary-')) @@ -123,23 +196,18 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption mkdirSync(dependencies, { recursive: true }) let pnpmVersion: string | undefined if (!options.pythonOnly) { - const nodeFilename = `node-v${lock.nodeVersion}-${artifact.nodeArchive}` - const nodeArchive = await downloadPrimaryRuntimeAsset(`https://nodejs.org/dist/v${lock.nodeVersion}/${nodeFilename}`, artifact.nodeSha256, paths.downloads) - const unpackedNode = join(staging, 'node') - mkdirSync(unpackedNode) - if (target === 'win-x64') await extractZip(nodeArchive, { dir: unpackedNode }) - else await extractTar({ file: nodeArchive, cwd: unpackedNode }) - const nodeSource = join(unpackedNode, nodeFilename.replace(/\.(?:zip|tar\.gz)$/u, '')) - mkdirSync(join(dependencies, 'node', 'bin'), { recursive: true }) - mkdirSync(join(dependencies, 'node', 'node_modules')) - writeFileSync(join(dependencies, 'node', 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n') - cpSync(join(nodeSource, ...(target === 'win-x64' ? ['node.exe'] : ['bin', 'node'])), - join(dependencies, 'node', 'bin', target === 'win-x64' ? 'node.exe' : 'node')) - cpSync(join(nodeSource, 'LICENSE'), join(dependencies, 'node', 'LICENSE')) - const require = createRequire(import.meta.url) - const pnpmManifest = require.resolve('pnpm') - pnpmVersion = (JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }).version - await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true }) + const node = join(dependencies, 'node') + await downloadNodeRuntime(target, node, paths.downloads) + mkdirSync(join(node, 'node_modules')) + writeFileSync(join(node, 'node_modules', 'README.txt'), 'Reserved for bundled Node packages. pnpm uses its default installation directories.\n') + if (options.pnpmArchive !== undefined) { + pnpmVersion = options.pnpmArchive.version + await downloadRuntimeNpmPackage(options.pnpmArchive, join(dependencies, 'pnpm'), paths.downloads) + } else { + const pnpmManifest = createRequire(import.meta.url).resolve('pnpm') + pnpmVersion = (JSON.parse(readFileSync(pnpmManifest, 'utf8')) as { version: string }).version + await cp(dirname(pnpmManifest), join(dependencies, 'pnpm'), { recursive: true, dereference: true }) + } } await extractTar({ file: await pythonArchive(target, paths.downloads), cwd: dependencies }) const manifest: PrimaryRuntimeManifest = { @@ -152,7 +220,7 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption pythonPackages: lock.pythonPackages, } const entries = workspaceDependencyPaths(output, manifest) - for (const wheel of [...artifact.wheels, ...lock.wheels]) { + for (const wheel of [...lock.targets[target].wheels, ...lock.wheels]) { await unpackPrimaryRuntimeWheel(await downloadPrimaryRuntimeAsset(wheel.url, wheel.sha256, paths.downloads), entries.pythonPackages) } writeFileSync(join(output, 'runtime.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) @@ -162,8 +230,8 @@ export async function preparePrimaryRuntime(options: PreparePrimaryRuntimeOption } finally { rmSync(staging, { recursive: true, force: true }) } - const require = createRequire(import.meta.url) - await prepareOfficeSkillAssets(join(dirname(require.resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets'), + const assets = options.skillSource ?? join(dirname(createRequire(import.meta.url).resolve('@deepseek-ai/dsh-skill-office/package.json')), 'assets') + await prepareOfficeSkillAssets(assets, join(paths.runtime, 'office-skills')) } diff --git a/scripts/python-runtime-download.spec.ts b/scripts/python-runtime-download.spec.ts new file mode 100644 index 0000000000..7d3b1bcfa4 --- /dev/null +++ b/scripts/python-runtime-download.spec.ts @@ -0,0 +1,135 @@ +/** Explicit resource acquisition through the Python carrier's private dispatcher. */ +import { spawn } from 'node:child_process' +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile, copyFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { expect, it } from 'vitest' + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'dsh-resource-download-')) + const bootstrap = join(root, 'node', 'runtime-bootstrap.mjs') + const platform = process.platform === 'darwin' ? 'macos' : process.platform === 'win32' ? 'win' : process.platform + const resources = join(root, `${platform}-${process.arch}`) + const cache = join(root, 'cache') + async function file(path: string, content: string) { + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, content) + } + await file(join(resources, 'downloads.json'), JSON.stringify({ identity: 'a'.repeat(64), office: [], pnpm: {} })) + await mkdir(dirname(bootstrap), { recursive: true }) + await copyFile(new URL('../python/sdk-runtime/runtime-bootstrap.mjs', import.meta.url), bootstrap) + await file(join(root, 'node/node_modules/@deepseek-ai/dsh-http-proxy/package.json'), '{"type":"module","exports":"./index.mjs"}') + await file(join(root, 'node/node_modules/@deepseek-ai/dsh-http-proxy/index.mjs'), 'export async function installProxyFromEnvironment(env) { if (typeof env.get !== "function") throw new Error("missing environment lookup"); return async () => {} }') + await file(join(root, 'node/node_modules/@deepseek-ai/dsh-sandbox-windows-acl/package.json'), '{"type":"module","exports":{"./runner":"./runner.mjs"}}') + await file(join(root, 'node/node_modules/@deepseek-ai/dsh-sandbox-windows-acl/runner.mjs'), '') + await file(join(root, 'node/node_modules/@deepseek-ai/dsh/package.json'), '{"type":"module"}') + await file(join(root, 'node/node_modules/@deepseek-ai/dsh/lib/bin.js'), 'export async function runCli() { if (process.argv[2]) { console.log(JSON.stringify({ argv: process.argv.slice(2) })); return } console.log(JSON.stringify({ primary: process.env.DSH_BUNDLED_PRIMARY_RUNTIME, office: process.env.DSH_BUNDLED_OFFICE_CLI })) }') + await file(join(root, 'node/primary-runtime.mjs'), ` + import { mkdir, writeFile } from 'node:fs/promises'; + import { join } from 'node:path'; + export async function downloadNodeRuntime(target, destination) { + if (process.env.DSH_RUNTIME_DOWNLOAD !== undefined) throw new Error("private selector leaked"); + await mkdir(join(destination, 'bin'), { recursive: true }); + await writeFile(join(destination, 'bin', process.platform === 'win32' ? 'node.exe' : 'node'), 'node'); + } + export async function preparePrimaryRuntime({ output }) { + if (process.env.DSH_RUNTIME_DOWNLOAD !== undefined) throw new Error("private selector leaked"); + await mkdir(join(output, 'primary-runtime'), { recursive: true }); + await writeFile(join(output, 'primary-runtime/runtime.json'), '{}'); + } + `) + await file(join(root, 'node/office-sidecar.mjs'), ` + import { mkdir, writeFile } from 'node:fs/promises'; + import { join } from 'node:path'; + export async function downloadOfficeSidecar(packages, destination) { + const directory = join(destination, 'node_modules/@deepseek-ai/libreoffice-kit/lib'); + await mkdir(directory, { recursive: true }); + await writeFile(join(directory, 'cli.js'), 'cli'); + if (process.env.DSH_FIXTURE_FAIL) throw new Error('fixture download failed'); + if (process.env.DSH_FIXTURE_BARRIER) { + process.stderr.write('ready\\n'); + await new Promise(resolve => process.stdin.once('data', resolve)); + process.stdin.pause(); + } + } + `) + function run(resource?: string, extra: NodeJS.ProcessEnv = {}, args: string[] = []) { + const child = spawn(process.execPath, [bootstrap, ...args], { + env: { ...process.env, DSH_RESOURCE_CACHE: cache, DSH_RUNTIME_DOWNLOAD: resource, DSH_BUNDLED_PRIMARY_RUNTIME: undefined, + DSH_OFFICE_SIDECAR: undefined, DSH_BUNDLED_OFFICE_CLI: undefined, ...extra }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stdout = '', stderr = '' + child.stdout.on('data', (data) => { stdout += String(data) }) + let reportReady: (() => void) | undefined + let failReady: ((error: Error) => void) | undefined + const ready = extra.DSH_FIXTURE_BARRIER === undefined ? Promise.resolve() + : new Promise((resolve, reject) => { reportReady = resolve; failReady = reject }) + child.stderr.on('data', (data) => { stderr += String(data); if (stderr.includes('ready\n')) reportReady?.() }) + const done = new Promise<{ code: number | null; stdout: string; stderr: string }>((resolve, reject) => { + child.once('error', reject) + child.once('close', (code) => { failReady?.(new Error(`download exited before readiness: ${stderr}`)); resolve({ code, stdout, stderr }) }) + }) + return { child, ready, done } + } + return { root, cache, run } +} + +it('starts without downloading, independently downloads either resource, and reuses completed caches', async () => { + const { root, cache, run } = await fixture() + try { + expect(await run().done).toMatchObject({ code: 0, stdout: '{}\n' }) + await expect(readdir(cache)).rejects.toMatchObject({ code: 'ENOENT' }) + const primary = await run('primary').done + expect(primary.code, primary.stderr).toBe(0) + expect(JSON.parse(primary.stdout)).toBe(join(cache, 'a'.repeat(64), 'primary', 'primary-runtime')) + await expect(readdir(join(cache, 'a'.repeat(64), 'office'))).rejects.toMatchObject({ code: 'ENOENT' }) + const office = await run('office').done + expect(office.code, office.stderr).toBe(0) + const location = JSON.parse(office.stdout) as string + expect(await readFile(join(location, 'node_modules/@deepseek-ai/libreoffice-kit/lib/cli.js'), 'utf8')).toBe('cli') + expect(await run('office', { DSH_FIXTURE_FAIL: '1' }).done).toMatchObject({ code: 0, stdout: office.stdout }) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('cleans failed staging and allows an explicit retry', async () => { + const { root, cache, run } = await fixture() + try { + const failed = await run('office', { DSH_FIXTURE_FAIL: '1' }).done + expect(failed.code).toBe(1) + expect(failed.stderr).toContain('fixture download failed') + expect(await readdir(join(cache, 'a'.repeat(64)))).toEqual([]) + expect((await run('office').done).code).toBe(0) + } finally { await rm(root, { recursive: true, force: true }) } +}) + +it('publishes one complete cache when two processes download concurrently', async () => { + const { root, cache, run } = await fixture() + const jobs = [run('office', { DSH_FIXTURE_BARRIER: '1' }), run('office', { DSH_FIXTURE_BARRIER: '1' })] + try { + await Promise.all(jobs.map(job => job.ready)) + for (const job of jobs) job.child.stdin.end('continue') + const results = await Promise.all(jobs.map(job => job.done)) + for (const result of results) expect(result.code, result.stderr).toBe(0) + expect(results[0]!.stdout).toBe(results[1]!.stdout) + expect(await readdir(join(cache, 'a'.repeat(64)))).toEqual(['office']) + } finally { + for (const job of jobs) if (job.child.exitCode === null && job.child.signalCode === null) job.child.kill() + await Promise.allSettled(jobs.map(job => job.done)) + await rm(root, { recursive: true, force: true }) + } +}) + + +it('leaves ordinary CLI arguments to the CLI and rejects an invalid private download selector', async () => { + const { root, cache, run } = await fixture() + try { + expect(await run(undefined, {}, ['--download-office']).done).toMatchObject({ + code: 0, stdout: JSON.stringify({ argv: ['--download-office'] }) + '\n', + }) + const rejected = await run('invalid').done + expect(rejected.code).toBe(1) + expect(rejected.stderr).toContain('DSH_RUNTIME_DOWNLOAD must select office or primary') + await expect(readdir(cache)).rejects.toMatchObject({ code: 'ENOENT' }) + } finally { await rm(root, { recursive: true, force: true }) } +}) diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index d79a0a3f88..417edb67d2 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -884,10 +884,18 @@ def main() -> None: def smoke_sdk_authoring(base_url: str, executable: Path, update_snapshots: bool) -> None: """Query the bundled Python and switch skills without replacing that environment.""" + + with tempfile.TemporaryDirectory(prefix="dsh-sdk-authoring-cache-") as cache_directory: + _smoke_sdk_authoring_cached(base_url, executable, update_snapshots, Path(cache_directory)) + + +def _smoke_sdk_authoring_cached(base_url: str, executable: Path, update_snapshots: bool, cache: Path) -> None: from deepseek_harness import DeepSeekHarness - resources = executable.with_name(executable.name.removeprefix("deepseek-harness-sdk-runtime-").removesuffix(".exe")) - manifest = json.loads((resources / "primary-runtime/runtime.json").read_text()) + cache_environment = {**os.environ, "DSH_RESOURCE_CACHE": str(cache), "DSH_RUNTIME_DOWNLOAD": "primary"} + primary = Path(json.loads(subprocess.check_output([str(executable)], encoding="utf-8", env=cache_environment))) + resources = primary.parent + manifest = json.loads((primary / "runtime.json").read_text()) for mode in ("default", "replacement", "disabled"): with tempfile.TemporaryDirectory(prefix="dsh-sdk-authoring-") as temporary: root = Path(temporary).resolve() @@ -903,7 +911,7 @@ def smoke_sdk_authoring(base_url: str, executable: Path, update_snapshots: bool) provider="deepseek-official", model="smoke-model", cwd=str(root), dsh_bin=str(executable), dsh_home=str(home), patches=(str(patch),), api_key="sk-keyless-smoke", base_url=base_url, - env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"}, + env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1", "DSH_RESOURCE_CACHE": str(cache)}, request_timeout_seconds=60, ) as harness: result = harness.run(AUTHORING_PROMPT, session_id="authoring") @@ -958,7 +966,9 @@ def smoke_sdk_office(executable: Path) -> None: shutil.copytree(source, destination) else: shutil.copy2(source, destination) - office = root / f"{stem}-office" + cache_environment = {**os.environ, "DSH_RESOURCE_CACHE": str(root / "cache")} + office = Path(json.loads(subprocess.check_output([str(relocated)], encoding="utf-8", env={**cache_environment, "DSH_RUNTIME_DOWNLOAD": "office"}))) + assert not (office.parent / "primary").exists(), "Office download unexpectedly prepared the authoring environment" adapter = office / "node_modules/@deepseek-ai/libreoffice-kit/package.json" native = stem.removeprefix("deepseek-harness-sdk-runtime-").replace("win-", "win32-").replace("macos-", "darwin-") declared = json.loads(adapter.read_text(encoding="utf-8")).get("optionalDependencies", {}) @@ -997,7 +1007,7 @@ def smoke_sdk_office(executable: Path) -> None: patches=(str(patch),), api_key="sk-keyless-smoke", base_url="http://127.0.0.1:9", - env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1"}, + env={"DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1", "DSH_RESOURCE_CACHE": str(root / "cache")}, # The startup plugin awaits a converter with a 120-second deadline before JSON-RPC is ready. initialize_timeout_seconds=180, request_timeout_seconds=180, @@ -1007,10 +1017,32 @@ def smoke_sdk_office(executable: Path) -> None: if result["backend"] != expected_backend: raise AssertionError(f"Office conversion did not use {expected_backend}: {result}") if not result["moduleUrl"].startswith(office.as_uri() + "/"): - raise AssertionError(f"Office module was not loaded from the relocated wheel: {result}") + raise AssertionError(f"Office module was not loaded from the downloaded sidecar: {result}") pdf = output.read_bytes() if len(pdf) < 100 or not pdf.startswith(b"%PDF-"): raise AssertionError(f"Office conversion produced an invalid PDF at {output}") + workbook = root / "input.xlsx" + with zipfile.ZipFile(workbook, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("[Content_Types].xml", '') + archive.writestr("_rels/.rels", '') + archive.writestr("xl/workbook.xml", '') + archive.writestr("xl/_rels/workbook.xml.rels", '') + archive.writestr("xl/worksheets/sheet1.xml", '1+20') + subprocess.run([sys.executable, "-c", """ +import sys, zipfile +from pathlib import Path +from xml.etree import ElementTree +from deepseek_harness.office import convert, render_images, recalculate +root = Path(sys.argv[1]) +assert convert(root / 'document.docx', root / 'python.pdf').backend == sys.argv[2] +rendered = render_images(root / 'python.pdf', root / 'images', pages=[1]) +assert rendered.images and rendered.images[0].path.read_bytes().startswith(b'\\x89PNG') +recalculate(root / 'input.xlsx', root / 'calculated.xlsx') +with zipfile.ZipFile(root / 'calculated.xlsx') as archive: + sheet = ElementTree.fromstring(archive.read('xl/worksheets/sheet1.xml')) + assert sheet.find('.//{*}c[@r="A1"]/{*}v').text == '3' +""", str(root), expected_backend], env={name: value for name, value in cache_environment.items() if name != "DSH_OFFICE_SIDECAR"}, check=True, timeout=180) + assert not (office.parent / "primary").exists(), "Python Office API unexpectedly downloaded authoring resources" print(f"smoke-python-runtime: relocated Office {result['backend']} DOCX produced {len(pdf)} PDF bytes") @@ -1336,6 +1368,8 @@ def smoke_sdk_dynamic_tools(base_url: str, executable: Path, update_snapshots: b "DSH_PERMISSION_MODE": "danger-full-access", "DSH_TELEMETRY_DISABLED": "1", "HOME": str(user_home), "USERPROFILE": str(user_home), "DSH_AGENTS_HOME": str(user_home / ".agents"), + # Pin the tool baseline independently of optional resources in the host cache. + "DSH_PRIMARY_RUNTIME": "", }, api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, ) as harness: @@ -2307,11 +2341,17 @@ def build_dynamic_tools_snapshot_files( assert changes == [[], [{"type": "tool_addition", "tool": {"type": "tool_reference", "name": "snapshot_ping"}}], []], changes assert requests[1]["messages"][:len(requests[0]["messages"])] == requests[0]["messages"], "tool addition changed earlier messages" replacements = [(str(cwd), "{{cwd}}"), (result.session_id, "{{session}}")] + header_references = {header["seq"]: f"{{{{header:{index + 1}}}}}" for index, header in enumerate(headers)} + snapshot_events = [ + {**event, "data": {**event["data"], "headerSeq": header_references[event["data"]["headerSeq"]]}} + if event["type"] == "developer/message" and "headerSeq" in event["data"] else event + for event in events if event["type"] in {"request/header", "request/context", "developer/message"} + ] evidence = { "finalResponse": result.final_response, "declarations": declarations, "requestToolChanges": changes, - "events": [event for event in events if event["type"] in {"request/header", "request/context", "developer/message"}], + "events": snapshot_events, "toolCalls": calls, "pingResult": ping, } diff --git a/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json b/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json index 7a8a75aa85..d93f0c33c1 100644 --- a/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json +++ b/scripts/snapshots/python-sdk-single-exe/dynamic-tools/tool-history.json @@ -56,7 +56,6 @@ "job_list", "job_output", "list_agents", - "load_workspace_dependencies", "read", "read_image", "send_message", @@ -87,7 +86,7 @@ }, { "type": "request/header", - "seq": 20, + "seq": 19, "time": 0, "data": { "header": { @@ -113,7 +112,6 @@ "job_list", "job_output", "list_agents", - "load_workspace_dependencies", "read", "read_image", "send_message", @@ -135,7 +133,7 @@ }, { "type": "developer/message", - "seq": 21, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -153,13 +151,13 @@ "role": "developer", "id": "{{messageId}}" }, - "headerSeq": 20 + "headerSeq": "{{header:2}}" }, "surfaceOp": "append" }, { "type": "request/header", - "seq": 28, + "seq": 26, "time": 0, "data": { "header": { @@ -185,7 +183,6 @@ "job_list", "job_output", "list_agents", - "load_workspace_dependencies", "read", "read_image", "send_message", @@ -206,7 +203,7 @@ }, { "type": "developer/message", - "seq": 29, + "seq": 27, "time": 0, "data": { "turn": 1,