fix(build): validate packaged spawn helpers

This commit is contained in:
Tianyi Cui
2026-07-29 21:49:21 +08:00
parent 98e6a0573f
commit 7b1e8978a9
12 changed files with 167 additions and 20 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md
2026-07-29-persistent-bash-str-replace-editor.md: 26949e7435bcbf05132662c308f33f322920c7eb 2026-07-29-persistent-bash-str-replace-editor.md: b1be9cc40e11b07b722877e666de0e0328636a05
2026-07-29-persistent-bash-str-replace-editor.zh.md: 6d5eadbfa68a59157e8f4bc148d03144bc665cb2 2026-07-29-persistent-bash-str-replace-editor.zh.md: 0bf100514e971dfa759f44dcca15a7ad6a2fdd8a

View File

@@ -16,7 +16,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m
`dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged. `dsh-system-prompt` accepts `includeHarnessIdentity: false`, while `dsh-agent-spine-demo` forwards that setting and accepts `toolBash: false`. A deployment can therefore own an exact persona and replace the spine's native Bash without duplicate prompt or tool registrations. Existing defaults remain unchanged.
Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present (or when `DSH_NODE_PTY_SPAWN_HELPER` explicitly selects one), preserving upstream lookup in ordinary Node runs; the executable and runtime-wheel builders fail before publication when the helper is absent, mismatched, or not executable. Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper`, each packaged runtime executable ships with an architecture-matched `-spawn-helper` sibling. A pinned `node-pty` patch resolves that sibling only when present, preserving upstream lookup in ordinary Node runs. The explicit `DSH_NODE_PTY_SPAWN_HELPER` override remains for a current external consumer that supplies a non-sibling helper. The executable and runtime-wheel builders inspect ELF or thin Mach-O headers and fail before publication when the helper is absent, mismatched, or not executable.
## Alternatives considered ## Alternatives considered

View File

@@ -16,7 +16,7 @@
`dsh-system-prompt` 接受 `includeHarnessIdentity: false``dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona并替换 spine 的原生 Bash而不会重复注册提示词或工具。既有默认值不变。 `dsh-system-prompt` 接受 `includeHarnessIdentity: false``dsh-agent-spine-demo` 会转发该设置,并接受 `toolBash: false`。因此部署可以拥有精确 persona并替换 spine 的原生 Bash而不会重复注册提示词或工具。既有默认值不变。
两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它(也可由 `DSH_NODE_PTY_SPAWN_HELPER` 显式指定),普通 Node 运行仍保留上游查找方式;若 helper 缺失、架构不匹配或不可执行,可执行文件与 runtime wheel 构建会在发布前失败。 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 会执行原生 `spawn-helper`,每个打包后的运行时可执行文件都会携带一个架构匹配的 `-spawn-helper` 伴随文件。固定版本的 `node-pty` 补丁只在该伴随文件存在时解析它,普通 Node 运行仍保留上游查找方式。显式的 `DSH_NODE_PTY_SPAWN_HELPER` 覆盖仍予保留,供当前提供非伴随 helper 的外部消费方使用。可执行文件与运行时 wheel 包的构建器会检查 ELF 或 thin Mach-O 文件头;若 helper 缺失、架构不匹配或不可执行,构建会在发布前失败。
## 考虑过的替代方案 ## 考虑过的替代方案

View File

@@ -2,7 +2,7 @@ diff --git a/lib/unixTerminal.js b/lib/unixTerminal.js
index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644 index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf988cf94c5 100644
--- a/lib/unixTerminal.js --- a/lib/unixTerminal.js
+++ b/lib/unixTerminal.js +++ b/lib/unixTerminal.js
@@ -26,10 +26,22 @@ var terminal_1 = require("./terminal"); @@ -26,10 +26,23 @@ var terminal_1 = require("./terminal");
var utils_1 = require("./utils"); var utils_1 = require("./utils");
var native = utils_1.loadNativeModule('pty'); var native = utils_1.loadNativeModule('pty');
var pty = native.module; var pty = native.module;
@@ -10,6 +10,7 @@ index 1ec12f796a822c78fba9ad7f6448c3987e325c23..5cd6b7d635f4752be5a6c5ff9cf9edf9
-helperPath = path.resolve(__dirname, helperPath); -helperPath = path.resolve(__dirname, helperPath);
-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); -helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
+// A current external embedded-runtime consumer supplies a non-sibling helper.
+var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; +var helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER;
+if (helperPath) { +if (helperPath) {
+ helperPath = path.resolve(helperPath); + helperPath = path.resolve(helperPath);
@@ -33,7 +34,7 @@ diff --git a/src/unixTerminal.ts b/src/unixTerminal.ts
index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644 index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf9771220392d17 100644
--- a/src/unixTerminal.ts --- a/src/unixTerminal.ts
+++ b/src/unixTerminal.ts +++ b/src/unixTerminal.ts
@@ -14,10 +14,20 @@ import { assign, loadNativeModule } from './utils'; @@ -14,10 +14,21 @@ import { assign, loadNativeModule } from './utils';
const native = loadNativeModule('pty'); const native = loadNativeModule('pty');
const pty: IUnixNative = native.module; const pty: IUnixNative = native.module;
@@ -41,6 +42,7 @@ index 98733dc0cd752b554bd94e45904ca341ad141bba..fa234291206617ae5a6d8605abf97712
-helperPath = path.resolve(__dirname, helperPath); -helperPath = path.resolve(__dirname, helperPath);
-helperPath = helperPath.replace('app.asar', 'app.asar.unpacked'); -helperPath = helperPath.replace('app.asar', 'app.asar.unpacked');
-helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked'); -helperPath = helperPath.replace('node_modules.asar', 'node_modules.asar.unpacked');
+// A current external embedded-runtime consumer supplies a non-sibling helper.
+let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER; +let helperPath = process.env.DSH_NODE_PTY_SPAWN_HELPER;
+if (helperPath) { +if (helperPath) {
+ helperPath = path.resolve(helperPath); + helperPath = path.resolve(helperPath);

8
pnpm-lock.yaml generated
View File

@@ -6,7 +6,7 @@ settings:
patchedDependencies: patchedDependencies:
'@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946 '@earendil-works/pi-tui@0.80.7': 6c30c5386c0159131e1361023cddf31377f5728962524841964373312c1ed946
node-pty@1.1.0: 4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15 node-pty@1.1.0: 7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6
importers: importers:
@@ -656,7 +656,7 @@ importers:
devDependencies: devDependencies:
node-pty: node-pty:
specifier: 1.1.0 specifier: 1.1.0
version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6)
packages/acp/acp: packages/acp/acp:
dependencies: dependencies:
@@ -3402,7 +3402,7 @@ importers:
dependencies: dependencies:
node-pty: node-pty:
specifier: ^1.1.0 specifier: ^1.1.0
version: 1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15) version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6)
schemastery: schemastery:
specifier: ^3.18.0 specifier: ^3.18.0
version: 3.18.0 version: 3.18.0
@@ -15287,7 +15287,7 @@ snapshots:
fetch-blob: 3.2.0 fetch-blob: 3.2.0
formdata-polyfill: 4.0.10 formdata-polyfill: 4.0.10
node-pty@1.1.0(patch_hash=4a1568bc9ef77084629054d0736430818818155abcd0dce581ef8c782e974c15): node-pty@1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6):
dependencies: dependencies:
node-addon-api: 7.1.1 node-addon-api: 7.1.1

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write python/sdk-runtime/README.md # pnpm run verify-translation-pairing --write python/sdk-runtime/README.md
README.md: ee3791eddf26b526316d4f3952793a03cc48841e README.md: 0869b4a9dce0e261b168f21c90a80faf81ea4a64
README.zh.md: 59d40ee56688cb377902ff126b7fa77606c7ad8b README.zh.md: 3f2342c4c66e24b8213c78d4e7530c3360497022

View File

@@ -8,7 +8,7 @@ Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`,
Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored:
- **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg-<platform>-<arch>` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built and validated as one runtime product. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. - **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg-<platform>-<arch>` plus its native `-spawn-helper` sibling (platform: `linux`/`macos`; arch: `x64`/`arm64`). The helper is required by `node-pty`; both files are built as one runtime product, and ELF or thin Mach-O headers must match the target. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists.
- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. - **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions.
Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding.

View File

@@ -8,7 +8,7 @@ Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`
两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略:
- **exe生产**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 及其原生 `-spawn-helper` 伴随文件platform`linux`/`macos`arch`x64`/`arm64`)。`node-pty` 需要该 helper构建与校验会把两者视作同一个运行时产物。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 - **exe生产**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg-<platform>-<arch>` 及其原生 `-spawn-helper` 伴随文件platform`linux`/`macos`arch`x64`/`arm64`)。`node-pty` 需要该 helper两者作为一个运行时产物构建,且 ELF 或 thin Mach-O 文件头必须与目标匹配。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。
- **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 - **`node`(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。
两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。

View File

@@ -16,6 +16,34 @@ _PLATFORMS = {
_SPAWN_HELPER_SUFFIX = "-spawn-helper" _SPAWN_HELPER_SUFFIX = "-spawn-helper"
def _spawn_helper_binary_target(header: bytes) -> str | None:
if (
len(header) >= 20
and header[:4] == b"\x7fELF"
and header[4] == 2
and header[5] == 1
):
machine = int.from_bytes(header[18:20], "little")
if machine == 62:
return "linux-x64"
if machine == 183:
return "linux-arm64"
if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe":
if int.from_bytes(header[4:8], "little") == 0x0100000C:
return "macos-arm64"
return None
def _validate_spawn_helper(path: Path, expected_target: str) -> None:
with path.open("rb") as helper:
actual_target = _spawn_helper_binary_target(helper.read(20))
if actual_target != expected_target:
raise RuntimeError(
f"runtime spawn helper binary mismatch: expected {expected_target}, "
f"found {actual_target or 'unsupported format or architecture'} at {path}"
)
def _host_platform_tag() -> str: def _host_platform_tag() -> str:
machine = platform.machine().lower() machine = platform.machine().lower()
arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine arch = "arm64" if machine in {"arm64", "aarch64"} else "x64" if machine in {"x86_64", "amd64"} else machine
@@ -39,13 +67,13 @@ class RuntimeBuildHook(BuildHookInterface):
) )
platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag() platform_tag = os.environ.get("DSH_RUNTIME_PLATFORM_TAG") or _host_platform_tag()
matches = [value for value in _PLATFORMS.values() if value[0] == platform_tag] matches = [(key, value) for key, value in _PLATFORMS.items() if value[0] == platform_tag]
if len(matches) != 1: if len(matches) != 1:
supported = ", ".join(value[0] for value in _PLATFORMS.values()) supported = ", ".join(value[0] for value in _PLATFORMS.values())
raise RuntimeError( raise RuntimeError(
f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}" f"unsupported DSH_RUNTIME_PLATFORM_TAG {platform_tag!r}; expected one of {supported}"
) )
expected_executable = matches[0][1] expected_target, (_, expected_executable) = matches[0]
runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir = Path(self.root) / "src" / "deepseek_harness_runtime" / "runtime"
runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else []) runtime_files = sorted(runtime_dir.glob("dsh-jsonrpc-agent-pkg-*") if runtime_dir.is_dir() else [])
executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)] executables = [path for path in runtime_files if not path.name.endswith(_SPAWN_HELPER_SUFFIX)]
@@ -64,6 +92,7 @@ class RuntimeBuildHook(BuildHookInterface):
for executable in [executables[0], helpers[0]]: for executable in [executables[0], helpers[0]]:
if executable.stat().st_mode & stat.S_IXUSR == 0: if executable.stat().st_mode & stat.S_IXUSR == 0:
raise RuntimeError(f"runtime executable is not executable: {executable}") raise RuntimeError(f"runtime executable is not executable: {executable}")
_validate_spawn_helper(helpers[0], expected_target)
build_data["pure_python"] = False build_data["pure_python"] = False
build_data["infer_tag"] = False build_data["infer_tag"] = False

View File

@@ -16,6 +16,18 @@ SCRIPT = ROOT / "scripts" / "build-python-release.py"
build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT))) build_python_release = SimpleNamespace(**runpy.run_path(str(SCRIPT)))
def helper_header(target: str) -> bytes:
header = bytearray(20)
if target.startswith("linux-"):
header[:6] = b"\x7fELF\x02\x01"
machine = 62 if target == "linux-x64" else 183
header[18:20] = machine.to_bytes(2, "little")
else:
header[:4] = b"\xcf\xfa\xed\xfe"
header[4:8] = (0x0100000C).to_bytes(4, "little")
return bytes(header)
def test_repository_version_matches_root_package_json() -> None: def test_repository_version_matches_root_package_json() -> None:
expected = json.loads((ROOT / "package.json").read_text())["version"] expected = json.loads((ROOT / "package.json").read_text())["version"]
@@ -45,7 +57,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non
executable.write_bytes(b"runtime") executable.write_bytes(b"runtime")
executable.chmod(0o755) executable.chmod(0o755)
spawn_helper = Path(f"{executable}-spawn-helper") spawn_helper = Path(f"{executable}-spawn-helper")
spawn_helper.write_bytes(b"helper") spawn_helper.write_bytes(helper_header("macos-arm64"))
spawn_helper.chmod(0o751) spawn_helper.chmod(0o751)
destination = tmp_path / "staging" destination = tmp_path / "staging"
@@ -59,7 +71,7 @@ def test_stage_runtime_copies_executable_and_spawn_helper(tmp_path: Path) -> Non
runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
assert (runtime_dir / executable.name).read_bytes() == b"runtime" assert (runtime_dir / executable.name).read_bytes() == b"runtime"
copied_helper = runtime_dir / spawn_helper.name copied_helper = runtime_dir / spawn_helper.name
assert copied_helper.read_bytes() == b"helper" assert copied_helper.read_bytes() == helper_header("macos-arm64")
assert copied_helper.stat().st_mode & stat.S_IXUSR assert copied_helper.stat().st_mode & stat.S_IXUSR
@@ -75,3 +87,42 @@ def test_stage_runtime_rejects_missing_spawn_helper(tmp_path: Path) -> None:
executable, executable,
executable.name, executable.name,
) )
@pytest.mark.parametrize("target", ["linux-x64", "linux-arm64", "macos-arm64"])
def test_spawn_helper_binary_target(target: str) -> None:
assert build_python_release.spawn_helper_binary_target(helper_header(target)) == target
def test_stage_runtime_rejects_mismatched_spawn_helper(tmp_path: Path) -> None:
executable = tmp_path / "dsh-jsonrpc-agent-pkg-linux-x64"
executable.write_bytes(b"runtime")
executable.chmod(0o755)
spawn_helper = Path(f"{executable}-spawn-helper")
spawn_helper.write_bytes(helper_header("linux-arm64"))
spawn_helper.chmod(0o755)
with pytest.raises(ValueError, match="expected linux-x64, found linux-arm64"):
build_python_release.stage_runtime(
tmp_path / "staging",
"1.2.3",
executable,
executable.name,
)
def test_stage_runtime_rejects_non_binary_spawn_helper(tmp_path: Path) -> None:
executable = tmp_path / "dsh-jsonrpc-agent-pkg-macos-arm64"
executable.write_bytes(b"runtime")
executable.chmod(0o755)
spawn_helper = Path(f"{executable}-spawn-helper")
spawn_helper.write_bytes(b"helper")
spawn_helper.chmod(0o755)
with pytest.raises(ValueError, match="unsupported format or architecture"):
build_python_release.stage_runtime(
tmp_path / "staging",
"1.2.3",
executable,
executable.name,
)

View File

@@ -7,7 +7,7 @@
*/ */
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
import { existsSync, mkdirSync, statSync } from 'node:fs' import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs'
import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises' import { chmod, copyFile, readFile, rm, writeFile } from 'node:fs/promises'
import { basename, join, resolve, sep } from 'node:path' import { basename, join, resolve, sep } from 'node:path'
import { parseArgs } from 'node:util' import { parseArgs } from 'node:util'
@@ -58,6 +58,24 @@ interface RuntimeProduct {
spawnHelper: string spawnHelper: string
} }
function spawnHelperBinaryTarget(path: string): string | undefined {
const header = readFileSync(path).subarray(0, 20)
if (header.length >= 20
&& header.subarray(0, 4).equals(Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
&& header[4] === 2
&& header[5] === 1) {
const machine = header.readUInt16LE(18)
if (machine === 62) return 'linux-x64'
if (machine === 183) return 'linux-arm64'
}
if (header.length >= 8 && header.readUInt32LE(0) === 0xfeedfacf) {
const cpuType = header.readUInt32LE(4)
if (cpuType === 0x01000007) return 'macos-x64'
if (cpuType === 0x0100000c) return 'macos-arm64'
}
return undefined
}
function isPlatform(value: string): value is Platform { function isPlatform(value: string): value is Platform {
return (PLATFORMS as readonly string[]).includes(value) return (PLATFORMS as readonly string[]).includes(value)
} }
@@ -347,7 +365,17 @@ class SingleExeBuild {
+ `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`, + `checked ${candidates.join(', ')}. Build each runtime on its target platform and architecture.`,
) )
} }
if (statSync(helper).mode & 0o111) return helper if (statSync(helper).mode & 0o111) {
const expected = `${target.platform}-${target.arch}`
const actual = spawnHelperBinaryTarget(helper)
if (actual !== expected) {
throw new Error(
`build-exe-for-python-sdk: node-pty spawn-helper binary mismatch: expected ${expected}, `
+ `found ${actual ?? 'unsupported format or architecture'} at ${helper}`,
)
}
return helper
}
throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`) throw new Error(`build-exe-for-python-sdk: node-pty spawn-helper is not executable: ${helper}`)
} }

View File

@@ -23,6 +23,35 @@ PLATFORMS = {
"macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"), "macos-arm64": ("macosx_11_0_arm64", "dsh-jsonrpc-agent-pkg-macos-arm64"),
} }
SPAWN_HELPER_SUFFIX = "-spawn-helper" SPAWN_HELPER_SUFFIX = "-spawn-helper"
EXECUTABLE_TARGETS = {value[1]: key for key, value in PLATFORMS.items()}
def spawn_helper_binary_target(header: bytes) -> str | None:
if (
len(header) >= 20
and header[:4] == b"\x7fELF"
and header[4] == 2
and header[5] == 1
):
machine = int.from_bytes(header[18:20], "little")
if machine == 62:
return "linux-x64"
if machine == 183:
return "linux-arm64"
if len(header) >= 8 and header[:4] == b"\xcf\xfa\xed\xfe":
if int.from_bytes(header[4:8], "little") == 0x0100000C:
return "macos-arm64"
return None
def validate_spawn_helper(path: Path, expected_target: str) -> None:
with path.open("rb") as helper:
actual_target = spawn_helper_binary_target(helper.read(20))
if actual_target != expected_target:
raise ValueError(
f"runtime spawn helper binary mismatch: expected {expected_target}, "
f"found {actual_target or 'unsupported format or architecture'} at {path}"
)
def main() -> None: def main() -> None:
@@ -142,6 +171,7 @@ def stage_runtime(destination: Path, version: str, executable: Path, executable_
raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}") raise FileNotFoundError(f"runtime spawn helper does not exist: {spawn_helper}")
if spawn_helper.stat().st_mode & stat.S_IXUSR == 0: if spawn_helper.stat().st_mode & stat.S_IXUSR == 0:
raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}") raise PermissionError(f"runtime spawn helper is not executable: {spawn_helper}")
validate_spawn_helper(spawn_helper, EXECUTABLE_TARGETS[executable_name])
copy_package(ROOT / "python" / "sdk-runtime", destination) copy_package(ROOT / "python" / "sdk-runtime", destination)
rewrite_version(destination / "pyproject.toml", version) rewrite_version(destination / "pyproject.toml", version)
runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime" runtime_dir = destination / "src" / "deepseek_harness_runtime" / "runtime"
@@ -186,6 +216,13 @@ def verify_wheel(
mode = archive.getinfo(executable).external_attr >> 16 mode = archive.getinfo(executable).external_attr >> 16
if mode & stat.S_IXUSR == 0: if mode & stat.S_IXUSR == 0:
raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}") raise RuntimeError(f"{wheel} runtime executable lost its executable bit: {executable}")
actual_target = spawn_helper_binary_target(archive.read(helpers[0])[:20])
expected_target = EXECUTABLE_TARGETS[platform[1]]
if actual_target != expected_target:
raise RuntimeError(
f"{wheel} spawn helper binary mismatch: expected {expected_target}, "
f"found {actual_target or 'unsupported format or architecture'}"
)
elif runtime_files: elif runtime_files:
raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}")
if package == "sdk": if package == "sdk":