From cddb58143d29d07e7cc91cf29962ac9c5d90309d Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Thu, 17 Sep 2026 18:35:49 +0800 Subject: [PATCH] fix(runtime): scope SDK diagnostics to the actual execution source Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../deepseek-harness-connector.md | 9 +++ .../loopx-managed-turn-operator-flow-smoke.py | 1 + ...opx-turn-managed-executor-binding-smoke.py | 58 ++++++++++++++++--- .../operator-provider-credential-smoke.py | 17 +++++- loopx/chat_agent.py | 5 +- loopx/chat_manager.py | 9 ++- .../control_plane/turn_driver/host_binding.py | 16 +++++ tests/test_manager_channel_binding.py | 14 ++++- tests/test_turn_managed_executor_binding.py | 47 +++++++++++++++ 9 files changed, 160 insertions(+), 16 deletions(-) diff --git a/docs/integrations/deepseek-harness-connector.md b/docs/integrations/deepseek-harness-connector.md index d45743b113..7d7aeb8ba3 100644 --- a/docs/integrations/deepseek-harness-connector.md +++ b/docs/integrations/deepseek-harness-connector.md @@ -197,6 +197,15 @@ line, `deepseek-v4-flash@high` in the shipped shape, with the provider prepended only when it is not the shipped one -- it is one line because every plan carries it, and the agent-facing output budget is a contract. +`runtime_probe` distinguishes an import probe (`scope: "probing_interpreter"`, +`module: "deepseek_harness"`) from an injected runner (`scope: "configured_runner"`, +`module: null`, no import attempted). Availability applies to the answering +interpreter or runner, not the whole machine, and does not prove provider +authentication. The Chat refusal directs the operator to run `loopx doctor` +in the service environment, check `python.executable`, and install the SDK in +that same environment before restarting. Interpreter paths stay in local doctor +output, outside the Turn payload. + `run-once --execute` fails closed on that verdict: status `unavailable`, no host invocation, no journal write, and no quota spend, with `dsh_runtime_unavailable`, `operator_credential_unconfigured`, or diff --git a/examples/loopx-managed-turn-operator-flow-smoke.py b/examples/loopx-managed-turn-operator-flow-smoke.py index f11ed160b6..b3badaad28 100644 --- a/examples/loopx-managed-turn-operator-flow-smoke.py +++ b/examples/loopx-managed-turn-operator-flow-smoke.py @@ -96,6 +96,7 @@ def main() -> None: machine_defaults=MANAGED_DEFAULTS, credential_source="machine_store", session=None, + module_probe=_runtime_installed, ) if channel.get("executor_endpoint") != "dsh": fail(f"the channel must resolve the machine's executor: {channel}") diff --git a/examples/loopx-turn-managed-executor-binding-smoke.py b/examples/loopx-turn-managed-executor-binding-smoke.py index bda5ca0f26..56a60fced4 100644 --- a/examples/loopx-turn-managed-executor-binding-smoke.py +++ b/examples/loopx-turn-managed-executor-binding-smoke.py @@ -27,13 +27,16 @@ from loopx.cli import main as cli_main # noqa: E402 from loopx.control_plane.turn_driver import executor as turn_executor # noqa: E402 from loopx.control_plane.turn_driver.host_binding import ( # noqa: E402 + DSH_RUNTIME_MODULE, DSH_RUNTIME_UNAVAILABLE, EXECUTOR_KIND_INDIVIDUAL, EXECUTOR_KIND_MANAGED, + MANAGED_RUNTIME_PROBE_SCHEMA_VERSION, OPERATOR_CREDENTIAL_UNCONFIGURED, REMEDY_CONFIGURE_DSH_RUNTIME, REMEDY_CONFIGURE_OPERATOR_CREDENTIAL, REMEDY_SELECT_INDIVIDUAL_HOST, + RUNTIME_PROBE_SCOPE_INTERPRETER, ) @@ -41,6 +44,7 @@ AGENT_ID = "codex-managed-executor-fixture" TODO_ID = "todo_managedexec01" CREDENTIAL_ENV = "DEEPSEEK_API_KEY" +CREDENTIAL_VALUE = "sk-fixture-operator" RUNTIME_MODULE = "deepseek_harness" @@ -239,6 +243,29 @@ def _managed_binding(payload: dict[str, Any]) -> dict[str, Any]: return binding +def _expect_probe(binding: dict[str, Any], *, available: bool) -> None: + """Pin the scope of the launchability verdict at the CLI boundary. + + ``managed_executor_binding`` answers from the interpreter that probes, and + one machine can hold a service environment where the dsh SDK resolves and a + checkout environment where it does not. The verdict is only actionable when + the readback says which environment answered, so the public payload this + smoke reads has to carry it -- for the plan, for the fail-closed refusal, + and for every executor kind, so no reader branches on the field's absence. + """ + + probe = binding["runtime_probe"] + assert probe["schema_version"] == MANAGED_RUNTIME_PROBE_SCHEMA_VERSION, probe + assert probe["module"] == DSH_RUNTIME_MODULE, probe + assert probe["scope"] == RUNTIME_PROBE_SCOPE_INTERPRETER, probe + assert probe["available"] is available, probe + # This readback is carried into the Turn execution payload, so it stays + # public-safe: no absolute path and no credential value. + serialized = json.dumps(probe) + assert "/" not in serialized, serialized + assert CREDENTIAL_VALUE not in serialized, serialized + + def main() -> int: with tempfile.TemporaryDirectory( prefix="loopx-turn-managed-executor-" @@ -254,9 +281,9 @@ def main() -> int: assert exit_code == 0, payload assert payload["host"]["kind"] == "codex-cli", payload uncredentialed_default = payload["managed_executor"] - assert ( - uncredentialed_default["executor_kind"] == EXECUTOR_KIND_INDIVIDUAL - ), uncredentialed_default + assert uncredentialed_default["executor_kind"] == EXECUTOR_KIND_INDIVIDUAL, ( + uncredentialed_default + ) assert uncredentialed_default["operator_credential_bound"] is False, ( uncredentialed_default ) @@ -264,7 +291,7 @@ def main() -> int: # 2. The credential resolves and authenticates the managed default. with ( - _operator_credential("sk-fixture-operator"), + _operator_credential(CREDENTIAL_VALUE), _harness_runtime(available=True), ): exit_code, payload = _run_cli(_plan_command(registry, runtime, project)) @@ -274,11 +301,13 @@ def main() -> int: assert bound["credential_env"] == CREDENTIAL_ENV, bound assert bound["operator_credential_bound"] is True, bound assert bound["available"] is True, bound + _expect_probe(bound, available=True) # 3. With the runtime genuinely missing the same plan reports the other - # typed reason rather than promising a launch. + # typed reason rather than promising a launch, and the unchanged + # verdict travels with the scope it was answered at. with ( - _operator_credential("sk-fixture-operator"), + _operator_credential(CREDENTIAL_VALUE), _harness_runtime(available=False), ): exit_code, payload = _run_cli(_plan_command(registry, runtime, project)) @@ -286,11 +315,13 @@ def main() -> int: missing = _managed_binding(payload) assert missing["available"] is False, missing assert missing["unavailable_reason"] == DSH_RUNTIME_UNAVAILABLE, missing + _expect_probe(missing, available=False) # 4. An explicit individual host stays selected even while the operator - # credential is configured, and makes no launch claim. + # credential is configured, and makes no launch claim. It probes no + # runtime, and the field is still present as an explicit ``None``. with ( - _operator_credential("sk-fixture-operator"), + _operator_credential(CREDENTIAL_VALUE), _harness_runtime(available=True), ): exit_code, payload = _run_cli( @@ -302,6 +333,7 @@ def main() -> int: assert individual["executor_kind"] == EXECUTOR_KIND_INDIVIDUAL, individual assert individual["available"] is None, individual assert individual["operator_credential_bound"] is False, individual + assert individual["runtime_probe"] is None, individual # 5. Executing an explicitly selected managed host without the # credential fails closed: typed status, no host invocation, no @@ -328,12 +360,16 @@ def main() -> int: ], refusal assert refusal["remediation_host"] == "codex-cli", refusal assert refusal["remediation_env_vars"] == [CREDENTIAL_ENV], refusal + # The operator reads the refusal, so the refusal is where the scope has + # to be visible: this environment answered "the credential is missing", + # not "the runtime is missing". + _expect_probe(refusal["managed_executor"], available=True) _expect_no_effects(refusal) _expect_no_journal(refusal, runtime) # 6. The same refusal covers a provably unlaunchable runtime. with ( - _operator_credential("sk-fixture-operator"), + _operator_credential(CREDENTIAL_VALUE), _harness_runtime(available=False), ): exit_code, refusal = _run_cli( @@ -353,6 +389,10 @@ def main() -> int: REMEDY_CONFIGURE_DSH_RUNTIME, REMEDY_SELECT_INDIVIDUAL_HOST, ], refusal + # The refusal that sends an operator to provision a runtime names the + # environment that could not import it, so the same readback cannot be + # taken for a machine-level fact. + _expect_probe(refusal["managed_executor"], available=False) _expect_no_effects(refusal) _expect_no_journal(refusal, runtime) diff --git a/examples/operator-provider-credential-smoke.py b/examples/operator-provider-credential-smoke.py index 44940527d2..f6ff9082df 100644 --- a/examples/operator-provider-credential-smoke.py +++ b/examples/operator-provider-credential-smoke.py @@ -59,6 +59,19 @@ def fail(message: str) -> None: raise SystemExit(f"operator provider credential smoke failed: {message}") +def _runtime_installed(*_args: object, **_kwargs: object) -> bool: + """Report the optional managed runtime as installed. + + This smoke is about which credential authenticates the managed host, not + about whether this host happens to have that runtime on disk, so it must not + read the machine it runs on. The typed ``dsh_runtime_unavailable`` refusal is + covered by ``examples/loopx-turn-managed-executor-binding-smoke.py`` and + ``tests/test_turn_managed_executor_binding.py``. + """ + + return True + + class _Handler(OperatorProviderRequestMixin): """Drive the real request mixin without opening a socket.""" @@ -133,7 +146,9 @@ def main() -> None: fail("an unconfigured machine must keep the individual default host") refused = managed_executor_binding( - "dsh", environ=operator_provider_environ(runtime_root) + "dsh", + environ=operator_provider_environ(runtime_root), + module_probe=_runtime_installed, ) if refused.get("unavailable_reason") != OPERATOR_CREDENTIAL_UNCONFIGURED: fail(f"the managed host must name the missing credential: {refused}") diff --git a/loopx/chat_agent.py b/loopx/chat_agent.py index 5d117f8aba..d309016e90 100644 --- a/loopx/chat_agent.py +++ b/loopx/chat_agent.py @@ -54,8 +54,9 @@ def _host_tool_gate(summary: str, next_action: str) -> dict[str, str]: AGENT_ENDPOINT_UNAVAILABLE = "agent_endpoint_unavailable" AGENT_ENDPOINT_NEXT_ACTIONS = { "dsh_runtime_unavailable": ( - "Install the DeepSeek Harness runtime (`python -m pip install " - "'loopx[deepseek-harness]'`) and restart LoopX Chat." + "The LoopX Chat service interpreter cannot import deepseek_harness. " + "Run `loopx doctor` in that service environment and check python.executable; " + "install `loopx[deepseek-harness]` in the same environment, then restart LoopX Chat." ), "operator_credential_unconfigured": ( "Set the managed executor credential (DEEPSEEK_API_KEY, with " diff --git a/loopx/chat_manager.py b/loopx/chat_manager.py index a70af92a87..153dbbccdb 100644 --- a/loopx/chat_manager.py +++ b/loopx/chat_manager.py @@ -4,7 +4,7 @@ import hashlib from pathlib import Path -from typing import Any, Mapping +from typing import Any, Callable, Mapping from .control_plane.operator_credential import ( env_text, @@ -451,6 +451,7 @@ def manager_channel_binding( session: Mapping[str, Any] | None = None, machine_defaults: Mapping[str, Any] | None = None, credential_source: str | None = None, + module_probe: Callable[[str], bool] | None = None, ) -> dict[str, Any]: """Project the steward channel's resolved executor, model, and their source. @@ -485,12 +486,15 @@ def manager_channel_binding( executor_kind = MANAGER_ENDPOINT_KINDS.get(endpoint, "") credential_env = "" execution_profile: str | None = None + runtime_probe: dict[str, Any] | None = None if executor_kind == MANAGER_EXECUTOR_KIND_MANAGED: - managed = managed_executor_binding(endpoint, environ=environ) + managed = managed_executor_binding(endpoint, environ=environ, module_probe=module_probe) credential_env = str(managed.get("credential_env") or "") execution_profile = managed.get("execution_profile") available: bool | None = managed.get("available") unavailable_reason: str | None = managed.get("unavailable_reason") + if isinstance(managed.get("runtime_probe"), Mapping): + runtime_probe = dict(managed["runtime_probe"]) else: available, unavailable_reason = None, None model, model_source = manager_model_resolution( @@ -523,6 +527,7 @@ def manager_channel_binding( "execution_profile": execution_profile, "available": available, "unavailable_reason": unavailable_reason, + "runtime_probe": runtime_probe, "model": model, "model_source": model_source, **manager_channel_session_mode_readback(session), diff --git a/loopx/control_plane/turn_driver/host_binding.py b/loopx/control_plane/turn_driver/host_binding.py index f23ffa0913..2720670931 100644 --- a/loopx/control_plane/turn_driver/host_binding.py +++ b/loopx/control_plane/turn_driver/host_binding.py @@ -73,6 +73,9 @@ # launchability fact this projection checks without side effects. DSH_RUNTIME_MODULE = "deepseek_harness" DSH_RUNTIME_UNAVAILABLE = "dsh_runtime_unavailable" +# Module availability is scoped to this interpreter, not the whole machine. +MANAGED_RUNTIME_PROBE_SCHEMA_VERSION = "managed_runtime_probe_v0" +RUNTIME_PROBE_SCOPE_INTERPRETER = "probing_interpreter" # A managed host is billed to the operator's own endpoint. Without the operator # credential (or an explicit injected runner) LoopX cannot authenticate that # endpoint, so it refuses instead of letting the managed default consume @@ -175,6 +178,10 @@ def managed_executor_binding( with it, the provider claims to authenticate. It is ``None`` for every non-managed executor because neither the profile nor the credential belongs to an individual or generic host. + + ``runtime_probe`` states what the ``dsh_runtime_unavailable`` verdict is a + claim about, so a reader does not take a process-level answer for a + machine-level fact. """ if host == MANAGED_HOST: @@ -212,6 +219,12 @@ def managed_executor_binding( "unavailable_remediation": _managed_unavailable_remediation( unavailable_reason ), + "runtime_probe": { + "schema_version": MANAGED_RUNTIME_PROBE_SCHEMA_VERSION, + "scope": "configured_runner" if dsh_runner_configured else RUNTIME_PROBE_SCOPE_INTERPRETER, + "module": None if dsh_runner_configured else DSH_RUNTIME_MODULE, + "available": runtime_available, + }, } return { "schema_version": MANAGED_EXECUTOR_BINDING_SCHEMA_VERSION, @@ -228,6 +241,9 @@ def managed_executor_binding( "available": None, "unavailable_reason": None, "unavailable_remediation": [], + # The same field exists for every executor kind so a reader never + # branches on its presence; only a managed executor probes a runtime. + "runtime_probe": None, } diff --git a/tests/test_manager_channel_binding.py b/tests/test_manager_channel_binding.py index 88149c7a62..548d6248b6 100644 --- a/tests/test_manager_channel_binding.py +++ b/tests/test_manager_channel_binding.py @@ -45,6 +45,9 @@ from loopx.chat_server import ChatHTTPServer, ChatRequestHandler from loopx.chat_store import ChatSessionStore from loopx.control_plane.turn_driver import host_binding +from loopx.control_plane.turn_driver.host_binding import ( + RUNTIME_PROBE_SCOPE_INTERPRETER, +) from loopx.extensions.lark.cli_resolution import LarkCliResolution @@ -563,7 +566,7 @@ def test_a_managed_host_without_a_credential_raises_the_credential_gate( assert "DEEPSEEK_API_KEY" in error.gate["next_action"] -def test_a_managed_host_without_its_runtime_names_the_install_step( +def test_missing_runtime_gate_identifies_the_service_environment( tmp_path, monkeypatch ): # The credential is configured and the runtime is missing, so the launch is @@ -575,7 +578,10 @@ def test_a_managed_host_without_its_runtime_names_the_install_step( assert error.error_code == "agent_endpoint_unavailable" assert error.gate["kind"] == "host_tool_gate" - assert "pip install" in error.gate["next_action"] + assert "service interpreter" in error.gate["next_action"] + assert "loopx doctor" in error.gate["next_action"] + assert "python.executable" in error.gate["next_action"] + assert "same environment" in error.gate["next_action"] def test_unknown_endpoint_keeps_the_untyped_lookup_error(tmp_path): @@ -651,6 +657,10 @@ def test_a_channel_without_a_session_reads_as_unbound(monkeypatch): MANAGER_CHANNEL_SESSION_MODE_SOURCE_UNBOUND ) assert binding["session_status"] is None + # The channel quotes the governed Turn surface's probe scope, so a surface + # showing `dsh_runtime_unavailable` can say which environment answered. + assert binding["runtime_probe"]["scope"] == RUNTIME_PROBE_SCOPE_INTERPRETER + assert binding["runtime_probe"]["available"] is True def test_an_unrecognized_session_mode_is_named_rather_than_coerced(): diff --git a/tests/test_turn_managed_executor_binding.py b/tests/test_turn_managed_executor_binding.py index e0dcd9b8b4..dd78cb4cde 100644 --- a/tests/test_turn_managed_executor_binding.py +++ b/tests/test_turn_managed_executor_binding.py @@ -2,21 +2,26 @@ from __future__ import annotations +import json + import pytest from loopx.control_plane.turn_driver.host_binding import ( + DSH_RUNTIME_MODULE, DSH_RUNTIME_UNAVAILABLE, EXECUTOR_KIND_GENERIC, EXECUTOR_KIND_INDIVIDUAL, EXECUTOR_KIND_MANAGED, INDIVIDUAL_TURN_HOST, MANAGED_EXECUTOR_BINDING_SCHEMA_VERSION, + MANAGED_RUNTIME_PROBE_SCHEMA_VERSION, MANAGED_TURN_HOST, OPERATOR_CREDENTIAL_UNCONFIGURED, REMEDY_CONFIGURE_DSH_RUNTIME, REMEDY_CONFIGURE_OPERATOR_CREDENTIAL, REMEDY_CORRECT_EXECUTION_PROFILE, REMEDY_SELECT_INDIVIDUAL_HOST, + RUNTIME_PROBE_SCOPE_INTERPRETER, managed_executor_unavailable_payload, managed_executor_binding, resolve_default_turn_host, @@ -59,9 +64,45 @@ def test_managed_executor_reports_the_operator_credential_and_endpoint(): "available": True, "unavailable_reason": None, "unavailable_remediation": [], + "runtime_probe": { + "schema_version": MANAGED_RUNTIME_PROBE_SCHEMA_VERSION, + "module": DSH_RUNTIME_MODULE, + "scope": RUNTIME_PROBE_SCOPE_INTERPRETER, + "available": True, + }, } +def test_the_runtime_verdict_states_what_it_is_a_claim_about(): + """A process-level answer must not read as a machine-level fact.""" + + present = managed_executor_binding( + "dsh", + environ={"DEEPSEEK_API_KEY": "sk-operator"}, + module_probe=_RUNTIME, + ) + absent = managed_executor_binding( + "dsh", + environ={"DEEPSEEK_API_KEY": "sk-operator"}, + module_probe=_NO_RUNTIME, + ) + individual = managed_executor_binding("codex-cli") + + # The probe follows the same seam the verdict does, so a caller that + # injects one gets both facts from one answer. + assert present["runtime_probe"]["available"] is True + assert absent["runtime_probe"]["available"] is False + assert absent["runtime_probe"]["scope"] == RUNTIME_PROBE_SCOPE_INTERPRETER + assert absent["runtime_probe"]["module"] == DSH_RUNTIME_MODULE + # An individual executor probes no runtime, and the field is still present + # so no reader branches on its absence. + assert individual["runtime_probe"] is None + # This readback travels into the Turn execution payload, so it must stay + # public-safe: no absolute path, no credential value. + serialized = json.dumps(absent["runtime_probe"]) + assert "/" not in serialized and "sk-operator" not in serialized, serialized + + def test_managed_executor_fails_closed_when_the_runtime_is_missing(): binding = managed_executor_binding( "dsh", @@ -184,6 +225,12 @@ def test_configured_runner_hook_counts_as_an_operator_credential_boundary(): assert binding["operator_credential_bound"] is True assert binding["available"] is True + assert binding["runtime_probe"] == { + "schema_version": MANAGED_RUNTIME_PROBE_SCHEMA_VERSION, + "scope": "configured_runner", + "module": None, + "available": True, + } def test_individual_and_generic_executors_are_not_operator_credential_bound():