From 59dae89c947a771e880e9449beaf57df37775672 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Fri, 25 Sep 2026 02:50:51 +0800 Subject: [PATCH] fix(release): make qualification deterministic on configured hosts Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../testing/vision_shell_host.py | 9 ++++++-- .../test_required_vision_closeout_behavior.py | 23 +++++++++++++++++++ tests/test_turn_default_host_binding.py | 7 ++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/loopx/control_plane/testing/vision_shell_host.py b/loopx/control_plane/testing/vision_shell_host.py index 63d7213e37..a2bb380f06 100644 --- a/loopx/control_plane/testing/vision_shell_host.py +++ b/loopx/control_plane/testing/vision_shell_host.py @@ -30,6 +30,8 @@ sys.exit(result['exit_code']) """ +_COMMAND_TIMEOUT_SECONDS = 120 + def shell_isolation_available() -> bool: return (sys.platform == "darwin" and Path("/usr/bin/sandbox-exec").is_file()) or bool(shutil.which("bwrap")) @@ -125,15 +127,18 @@ def execute(self, command: str) -> tuple[str, int]: self._active = True process = subprocess.Popen([*self._sandbox(), "/bin/sh", "-c", command], cwd=self.project, env=env, stdout=output, stderr=subprocess.STDOUT, start_new_session=True) + group_killed = False try: - code = process.wait(timeout=120) + code = process.wait(timeout=_COMMAND_TIMEOUT_SECONDS) except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) + group_killed = True process.wait() code = 124 finally: try: - os.killpg(process.pid, signal.SIGKILL) + if not group_killed: + os.killpg(process.pid, signal.SIGKILL) except ProcessLookupError: pass with self._invocation_lock: diff --git a/tests/control_plane/test_required_vision_closeout_behavior.py b/tests/control_plane/test_required_vision_closeout_behavior.py index 2cf7b692e1..55898981f7 100644 --- a/tests/control_plane/test_required_vision_closeout_behavior.py +++ b/tests/control_plane/test_required_vision_closeout_behavior.py @@ -14,6 +14,7 @@ from loopx.control_plane.testing.replan_semantic_action_behavior import ( DoubaoReplanSemanticActionBehaviorActor, _build_fixture, ) +from loopx.control_plane.testing import vision_shell_host from loopx.control_plane.testing.vision_shell_host import VisionShellHost, shell_isolation_available pytestmark = pytest.mark.skipif(not shell_isolation_available(), reason="Native shell needs sandbox-exec or bubblewrap") @@ -198,6 +199,28 @@ def test_os_boundary_protects_inputs_authority_private_data_and_network(tmp_path host.close() +def test_timed_out_shell_kills_process_group_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + fixture = _build_fixture(tmp_path / "fixture", required_vision=True) + host = VisionShellHost(fixture.project_root, lambda *args: "ok", turn_instance_id="shell-timeout-test") + original_killpg = vision_shell_host.os.killpg + calls: list[int] = [] + + def kill_once(pid: int, sig: int) -> None: + calls.append(pid) + if len(calls) > 1: + raise AssertionError("A timed-out process group must not be killed twice") + original_killpg(pid, sig) + + monkeypatch.setattr(vision_shell_host, "_COMMAND_TIMEOUT_SECONDS", 0.05) + monkeypatch.setattr(vision_shell_host.os, "killpg", kill_once) + try: + _, code = host.execute("sleep 5") + assert code == 124 + assert len(calls) == 1 + finally: + host.close() + + def test_actor_cannot_shadow_the_trusted_cli_in_its_writable_project(tmp_path: Path) -> None: fixture = _build_fixture(tmp_path / "oracle", required_vision=True) def check_real_cli(request: Mapping[str, Any]) -> ScriptedAssistantAction: diff --git a/tests/test_turn_default_host_binding.py b/tests/test_turn_default_host_binding.py index e3893c73a5..5541e93691 100644 --- a/tests/test_turn_default_host_binding.py +++ b/tests/test_turn_default_host_binding.py @@ -5,6 +5,7 @@ import pytest from loopx.cli import build_parser +from loopx.control_plane import operator_provider from loopx.control_plane.operator_credential import configured_operator_credential from loopx.control_plane.turn_driver.host_binding import ( INDIVIDUAL_TURN_HOST, @@ -18,6 +19,12 @@ ) +@pytest.fixture(autouse=True) +def isolated_machine_credential_store(tmp_path, monkeypatch): + """CLI default tests must not read the developer machine's provider store.""" + monkeypatch.setattr(operator_provider, "DEFAULT_RUNTIME_ROOT", tmp_path / "machine") + + def test_managed_credential_selects_the_managed_default_host(): assert MANAGED_TURN_HOST == "dsh" environ = {"DEEPSEEK_API_KEY": "sk-operator"}