Forráskód Böngészése

review: client-owned default-config injection; tar the bare exe artifact

Address the three ds-review-bot warnings on #253:

- An empty DSH_CORDIS_CONFIG now counts as absent when deciding whether
  to inject the bundled default config, matching the runtime bin's
  config-discovery semantics.
- The injection moves from DeepSeekHarness into HarnessClient.start(),
  so the low-level client's default bundled launch also boots without
  callers duplicating the env setup.
- The bare single-file exe artifact ships inside a tar.gz like the
  Python bundle: upload-artifact's zip transport drops the executable
  bit.
imccyu 3 hónapja
szülő
commit
8fb70c7d46

+ 24 - 10
.github/workflows/build-exe-for-python-sdk.yml

@@ -8,14 +8,16 @@ name: Build single-exe
 # the dsh-jsonrpc-agent-pkg closure manifest with @yao-pkg/pkg into
 # dist-exe/.
 #
-# Each build leg uploads two artifacts:
+# Each build leg uploads two artifacts (both tar.gz — tar keeps file modes,
+# so the exe's executable bit survives; upload-artifact's zip transport
+# does not preserve them):
 #   - dsh-jsonrpc-agent-pkg-<target> — the bare single-file exe, for
 #     consumers that want just the binary.
-#   - deepseek-harness-python-<target> — the whole python/ directory as a
-#     tar.gz with that exe already embedded (the build script syncs it into
-#     the Python runtime package): unpack and both packages pip install
-#     as-is, the checked-in default runtime/cordis.yml is editable in
-#     place, and the embedded exe also runs directly.
+#   - deepseek-harness-python-<target> — the whole python/ directory with
+#     that exe already embedded (the build script syncs it into the Python
+#     runtime package): unpack and both packages pip install as-is, the
+#     checked-in default runtime/cordis.yml is editable in place, and the
+#     embedded exe also runs directly.
 #
 # Two explicit triggers, deliberately no per-commit CI: the exe is a
 # release-style deliverable, and the build (full pnpm build + pnpm deploy +
@@ -154,10 +156,24 @@ jobs:
       - name: Build single-exe
         run: pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets=${{ matrix.target }}
 
+      # The bare exe ships inside a tar.gz (the mode-preservation note in
+      # the header): uploading dist-exe/ bare would hand consumers a 0644
+      # file that subprocess.Popen refuses to run.
+      - name: Pack bare exe
+        id: pack-exe
+        env:
+          TARGET: ${{ matrix.target }}
+        run: |
+          set -euo pipefail
+          exe_tar="dsh-jsonrpc-agent-pkg-${TARGET}.tar.gz"
+          tar -czf "$exe_tar" -C dist-exe .
+          ls -lh "$exe_tar"
+          echo "tar=$exe_tar" >> "$GITHUB_OUTPUT"
+
       - uses: actions/upload-artifact@v6
         with:
           name: dsh-jsonrpc-agent-pkg-${{ matrix.target }}
-          path: dist-exe/
+          path: ${{ steps.pack-exe.outputs.tar }}
           if-no-files-found: error
 
       # After the build step, python/ is already in its complete
@@ -165,9 +181,7 @@ jobs:
       # python/sdk-runtime/src/deepseek_harness_runtime/runtime/ next to the
       # checked-in default cordis.yml — so packing is all that is left.
       # Everything goes under one top-level deepseek-harness-python/
-      # directory so unpacking never scatters files. Shipping a tar (rather
-      # than uploading the tree bare) preserves the exe's executable bit —
-      # tar keeps file modes; upload-artifact's zip does not.
+      # directory so unpacking never scatters files.
       #
       # Excluded: runtime/node/ (dev-only node-mode carrier, ~140MB) plus
       # __pycache__ / .pytest_cache / .venv / node_modules anywhere (install

+ 2 - 2
python/sdk-runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-README.md: 5543b0ba00816c230dbe4e5bba11b7bf4b38096d
-README.zh.md: 163b6d8e8373f0f770a5d95be688785907108b8a
+README.md: 4cf48c55f40ec1c8632582546dcbfb990bbaa660
+README.zh.md: 0c82569be45b5cefa2ca025728945be1bdefc541

+ 1 - 1
python/sdk-runtime/README.md

@@ -13,7 +13,7 @@ Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injecte
 
 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.
 
-Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow and place it in the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers.
+Missing carriers raise `FileNotFoundError` naming the acquisition routes: build via `scripts/build-exe-for-python-sdk.ts` in a deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` CI workflow (a tar.gz — tar preserves the executable bit) and unpack it into the runtime directory. Acquisition strategy is deliberately separate from the lookup interface, so an on-demand download can replace it later without touching callers.
 
 ## Resolution API
 

+ 1 - 1
python/sdk-runtime/README.zh.md

@@ -13,7 +13,7 @@ Python SDK 的运行时载体包(dist 名 `deepseek-harness-runtime-bin`,模
 
 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的 deploy root——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。
 
-载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物并放入 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。
+载体缺失时抛出 `FileNotFoundError` 并写明获取途径:在 deepseek-harness 检出中经 `scripts/build-exe-for-python-sdk.ts` 构建,或下载 `build-exe-for-python-sdk` CI 工作流的对应平台产物(tar.gz——tar 保留可执行位)并解包到 runtime 目录。获取策略与查找接口刻意分离,之后可以换成按需下载而不动任何调用方。
 
 ## 解析 API
 

+ 2 - 1
python/sdk-runtime/src/deepseek_harness_runtime/__init__.py

@@ -36,7 +36,8 @@ _ARCH_TAGS = {"x86_64": "x64", "amd64": "x64", "arm64": "arm64", "aarch64": "arm
 _EXE_ACQUISITION_HINT = (
     "Two ways to get the executable: run `scripts/build-exe-for-python-sdk.ts` (via tsx) in a "
     "deepseek-harness checkout, or download the platform artifact of the `build-exe-for-python-sdk` "
-    "CI workflow; place the file in this package's runtime/ directory. For local development "
+    "CI workflow (a tar.gz — tar preserves the executable bit) and unpack it into this package's "
+    "runtime/ directory. For local development "
     "against a repo source build, explicitly select the dev-only node carrier with "
     f"{RUNTIME_MODE_ENV_VAR}=node (or resolve_bundled_launch_args('node'))."
 )

+ 2 - 2
python/sdk/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write
-README.md: 67dbd04520ac01318c1f21393c12b7bf4965b4cb
-README.zh.md: 36ea4df4ff73e12ce3fda5bbee4108584dd5ad92
+README.md: ef9f8703f3bdb7b6d2dca28639f3b794083f7905
+README.zh.md: 31b6e87d7b75cc10cb799862fe6eb0f1f26dccd6

+ 1 - 1
python/sdk/README.md

@@ -24,4 +24,4 @@ with DeepSeekHarness(
 `assistant/message` event in the turn. Use `TurnResult.events` for the complete
 event stream, including intermediate assistant messages and tool activity.
 
-The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. If neither `cordis` nor `DSH_CORDIS_CONFIG` is set and the launch resolves to the bundled runtime, the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them.
+The same behavior can be selected for the runtime subprocess with `DSH_CORDIS_CONFIG`. The injection lives in `HarnessClient.start()`, so the low-level client's default launch gets it too: when the launch resolves to the bundled runtime and neither `cordis` nor a non-empty `DSH_CORDIS_CONFIG` is set (the runtime treats an empty value as absent, and so does the injection check), the bundled default configuration is used; an explicit `runtime_bin` or `launch_args_override` disables the injection entirely. See the [sdk-runtime README](../sdk-runtime/README.md) for the runtime carriers (production exe vs dev-only node closure) and how to obtain them.

+ 1 - 1
python/sdk/README.zh.md

@@ -18,4 +18,4 @@ with DeepSeekHarness(
 
 `TurnResult.final_response` 是本轮次最后一个 `assistant/message` 事件的文本内容。完整的事件流(包括中间的助手消息与工具活动)用 `TurnResult.events` 获取。
 
-同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。若 `cordis` 与 `DSH_CORDIS_CONFIG` 均未设置,且启动解析到内置运行时,则使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。
+同样的行为也可以用 `DSH_CORDIS_CONFIG` 为运行时子进程选定。注入逻辑位于 `HarnessClient.start()`,因此低层客户端的默认启动同样享有它:当启动解析到内置运行时,且 `cordis` 与非空的 `DSH_CORDIS_CONFIG` 均未设置时(运行时把空值当作缺省,注入检查与之一致),使用内置的默认配置;显式给出 `runtime_bin` 或 `launch_args_override` 则完全禁用注入。运行时载体(生产用 exe 与仅限开发的 node 闭包)及其获取方式见 [sdk-runtime README](../sdk-runtime/README.md)。

+ 0 - 24
python/sdk/src/deepseek_harness/api.py

@@ -1,6 +1,5 @@
 from __future__ import annotations
 
-import os
 import uuid
 from dataclasses import dataclass, field
 from pathlib import Path
@@ -60,8 +59,6 @@ class DeepSeekHarness:
             env["DSH_SESSION_ROOT"] = self.config.session_root
         if self.config.cordis is not None:
             env["DSH_CORDIS_CONFIG"] = self.config.cordis
-        else:
-            self._inject_bundled_default_config(env)
         env["DSH_CWD"] = cwd
         if self.config.base_url is not None:
             env["DEEPSEEK_BASE_URL"] = self.config.base_url
@@ -109,27 +106,6 @@ class DeepSeekHarness:
         self._client.close()
         self._initialized = False
 
-    def _inject_bundled_default_config(self, env: dict[str, str]) -> None:
-        """Restore the zero-config experience over the config-mandatory bundled runtime.
-
-        The bundled runtime (single-file exe or the dev-only node closure)
-        always demands an explicit config. When the caller neither provided
-        ``cordis`` nor selected a runtime explicitly (``runtime_bin`` /
-        ``launch_args_override``), and no ambient ``DSH_CORDIS_CONFIG`` exists,
-        inject the runtime package's checked-in default cordis.yml. With an
-        explicit runtime or config channel the SDK stays out of the way.
-        """
-        uses_bundled_runtime = self.config.runtime_bin is None and self.config.launch_args_override is None
-        if not uses_bundled_runtime or "DSH_CORDIS_CONFIG" in env or "DSH_CORDIS_CONFIG" in os.environ:
-            return
-        try:
-            from deepseek_harness_runtime import bundled_default_config_path
-        except ImportError:
-            # Only the runtime package's absence reaches here; swallow it so
-            # HarnessClient.start() reports the actionable install error.
-            return
-        env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path())
-
     def start_session(self, session_id: str | None = None) -> "Session":
         self.start()
         return Session(self, session_id or f"session-{uuid.uuid4().hex}")

+ 27 - 0
python/sdk/src/deepseek_harness/client.py

@@ -67,6 +67,7 @@ class HarnessClient:
         env = os.environ.copy()
         if self.config.env:
             env.update(self.config.env)
+        self._inject_bundled_default_config(env)
         self._proc = subprocess.Popen(
             args,
             stdin=subprocess.PIPE,
@@ -411,6 +412,32 @@ class HarnessClient:
             ) from exc
         return resolve_bundled_launch_args()
 
+    def _inject_bundled_default_config(self, env: dict[str, str]) -> None:
+        """Restore the zero-config experience over the config-mandatory bundled runtime.
+
+        The bundled runtime (single-file exe or the dev-only node closure)
+        always demands an explicit config. When the launch resolves to the
+        bundled runtime (no ``runtime_bin`` / ``bridge_bin`` /
+        ``launch_args_override``) and the merged subprocess environment has no
+        non-empty ``DSH_CORDIS_CONFIG`` — the runtime bin treats an empty
+        value as absent, so this does too — inject the runtime package's
+        checked-in default cordis.yml. With an explicit runtime or config
+        channel the client stays out of the way.
+        """
+        uses_bundled_runtime = (
+            self.config.launch_args_override is None
+            and self.config.runtime_bin is None
+            and self.config.bridge_bin is None
+        )
+        if not uses_bundled_runtime or env.get("DSH_CORDIS_CONFIG"):
+            return
+        # Cannot fail: _default_launch_args() already imported the runtime
+        # package on this (bundled) path, raising the actionable install
+        # error when it is absent.
+        from deepseek_harness_runtime import bundled_default_config_path
+
+        env["DSH_CORDIS_CONFIG"] = str(bundled_default_config_path())
+
     def _unsubscribe_notifications(self, subscription_id: str) -> None:
         with self._lock:
             self._notification_subscribers.pop(subscription_id, None)

+ 11 - 6
python/sdk/tests/test_bundled_runtime.py

@@ -17,7 +17,7 @@ import pytest
 
 from deepseek_harness import DeepSeekHarness, HarnessClient, HarnessConfig
 from deepseek_harness.errors import TransportClosedError
-from deepseek_harness_runtime import bundled_default_config_path, resolve_bundled_launch_args
+from deepseek_harness_runtime import resolve_bundled_launch_args
 
 _MODES = ("exe", "node")
 
@@ -99,12 +99,16 @@ def test_bundled_runtime_surfaces_unbundled_plugin_failure(tmp_path: Path, mode:
 
 
 @pytest.mark.parametrize("mode", _MODES)
+@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"])
 def test_zero_config_run_injects_bundled_default_cordis_config(
-    tmp_path: Path, mode: str, monkeypatch: pytest.MonkeyPatch
+    tmp_path: Path, mode: str, ambient_config: str | None, monkeypatch: pytest.MonkeyPatch
 ) -> None:
     _launch_args(mode)  # skip early when this carrier is unavailable
     monkeypatch.setenv("DSH_RUNTIME_MODE", mode)
-    monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False)
+    if ambient_config is None:
+        monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False)
+    else:
+        monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config)
 
     harness = DeepSeekHarness(
         model="deepseek-v4-pro",
@@ -114,7 +118,8 @@ def test_zero_config_run_injects_bundled_default_cordis_config(
         base_url="http://127.0.0.1:9",
         request_timeout_seconds=120,
     )
-    assert harness.client.config.env is not None
-    assert harness.client.config.env["DSH_CORDIS_CONFIG"] == str(bundled_default_config_path())
     with harness:
-        pass  # __enter__ boots the runtime on the injected default config; __exit__ shuts it down
+        # __enter__ boots the runtime, which exits with a usage error unless
+        # HarnessClient.start() injected the bundled default config over the
+        # unset/empty DSH_CORDIS_CONFIG; __exit__ shuts it down.
+        pass

+ 46 - 2
python/sdk/tests/test_client.py

@@ -548,13 +548,23 @@ with open(os.environ["SEEN"], "w") as seen:
         json.loads(line)
 
 
-def test_client_uses_bundled_runtime_package_by_default(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+def _install_fake_bundled_runtime(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> Path:
+    """Fake the deepseek-harness-runtime-bin package on sys.path.
+
+    A stub exe that dumps DSH_CORDIS_CONFIG to $ENV_DUMP before serving
+    initialize/shutdown, plus a module exposing the resolution surface the
+    client consumes. Returns the fake bundled default config path.
+    """
     runtime = tmp_path / "dsh-jsonrpc-agent"
     runtime.write_text(
         """#!/usr/bin/env python3
 import json
+import os
 import sys
 
+json.dump({"DSH_CORDIS_CONFIG": os.environ.get("DSH_CORDIS_CONFIG")}, open(os.environ["ENV_DUMP"], "w"))
 for line in sys.stdin:
     msg = json.loads(line)
     if msg.get("method") == "initialize":
@@ -566,22 +576,56 @@ for line in sys.stdin:
     )
     runtime.chmod(0o755)
 
+    default_config = tmp_path / "default-cordis.yml"
     module_dir = tmp_path / "deepseek_harness_runtime"
     module_dir.mkdir()
     (module_dir / "__init__.py").write_text(
         f"""
 def resolve_bundled_launch_args(mode=None):
     return ({str(runtime)!r},)
+
+
+def bundled_default_config_path():
+    return {str(default_config)!r}
 """.strip()
     )
 
     monkeypatch.syspath_prepend(str(tmp_path))
     monkeypatch.delitem(sys.modules, "deepseek_harness_runtime", raising=False)
+    return default_config
+
 
-    with HarnessClient() as client:
+@pytest.mark.parametrize("ambient_config", [None, ""], ids=["unset", "empty-counts-as-absent"])
+def test_client_default_launch_uses_bundled_runtime_and_injects_default_config(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ambient_config: str | None
+) -> None:
+    env_dump = tmp_path / "env.json"
+    default_config = _install_fake_bundled_runtime(tmp_path, monkeypatch)
+    if ambient_config is None:
+        monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False)
+    else:
+        monkeypatch.setenv("DSH_CORDIS_CONFIG", ambient_config)
+
+    with HarnessClient(HarnessConfig(env={"ENV_DUMP": str(env_dump)})) as client:
         init = client.initialize(cwd="/workspace", model="deepseek-v4-pro")
 
     assert init.serverInfo.name == "bundled-runtime"
+    assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == str(default_config)
+
+
+def test_client_respects_explicit_config_over_bundled_default(
+    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+    env_dump = tmp_path / "env.json"
+    _install_fake_bundled_runtime(tmp_path, monkeypatch)
+    monkeypatch.delenv("DSH_CORDIS_CONFIG", raising=False)
+
+    with HarnessClient(
+        HarnessConfig(env={"ENV_DUMP": str(env_dump), "DSH_CORDIS_CONFIG": "./explicit.yml"})
+    ) as client:
+        client.initialize(cwd="/workspace", model="deepseek-v4-pro")
+
+    assert json.loads(env_dump.read_text())["DSH_CORDIS_CONFIG"] == "./explicit.yml"
 
 
 def test_client_reports_missing_bundled_runtime_dependency(monkeypatch: pytest.MonkeyPatch) -> None: