Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions loopx/control_plane/testing/vision_shell_host.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions tests/control_plane/test_required_vision_closeout_behavior.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand Down
7 changes: 7 additions & 0 deletions tests/test_turn_default_host_binding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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"}
Expand Down
Loading