diff --git a/src/hqt/tui/app.py b/src/hqt/tui/app.py index 82a1de1..2726734 100644 --- a/src/hqt/tui/app.py +++ b/src/hqt/tui/app.py @@ -10,6 +10,7 @@ from textual.screen import ModalScreen from textual.theme import Theme from textual.widgets import Footer +from hqt import sandbox from hqt.config import get_settings from hqt.git import worktree from hqt.db.engine import ensure_db, get_engine, get_session_factory @@ -255,10 +256,10 @@ class HqtApp(App): project_path = project.path def on_dismiss( - result: tuple[str, str, str | None, str | None] | None, + result: tuple[str, str, str | None, str | None, object] | None, ) -> None: if result: - harness_name, nickname, model, worktree_branch = result + harness_name, nickname, model, worktree_branch, sandbox_policy = result async def _do() -> None: create_result = await self._sessions().create_session( @@ -267,6 +268,7 @@ class HqtApp(App): nickname, model, worktree_branch=worktree_branch, + sandbox=sandbox_policy, ) if not create_result.spawn_ok: first_line = next( @@ -296,7 +298,14 @@ class HqtApp(App): except Exception as exc: # noqa: BLE001 - keep the worker from crashing the TUI log.warning("is_git_repo check failed for %s: %s", project_path, exc) is_git_repo = False - self.push_screen(NewSessionScreen(harness_names, is_git_repo), on_dismiss) + self.push_screen( + NewSessionScreen( + harness_names, + is_git_repo, + sandbox_available=sandbox.is_available(), + ), + on_dismiss, + ) self.run_worker(_open()) diff --git a/src/hqt/tui/screens/new_session.py b/src/hqt/tui/screens/new_session.py index 4933cf9..2b43fd6 100644 --- a/src/hqt/tui/screens/new_session.py +++ b/src/hqt/tui/screens/new_session.py @@ -1,15 +1,24 @@ from textual.app import ComposeResult from textual.containers import Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Button, Checkbox, Input, Label, Select +from textual.widgets import Button, Checkbox, Input, Label, Select, Switch from hqt.git import worktree +from hqt.sandbox import SandboxPolicy -class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | None]): - def __init__(self, harness_names: list[str], is_git_repo: bool) -> None: +class NewSessionScreen( + ModalScreen[tuple[str, str, str | None, str | None, SandboxPolicy | None] | None] +): + def __init__( + self, + harness_names: list[str], + is_git_repo: bool = False, + sandbox_available: bool = False, + ) -> None: self.harness_names = harness_names self._is_git_repo = is_git_repo + self.sandbox_available = sandbox_available super().__init__() def compose(self) -> ComposeResult: @@ -43,6 +52,26 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non branch_input = Input(placeholder="branch name", id="branch-input") branch_input.display = False yield branch_input + yield Label("Sandboxed (bubblewrap):") + yield Switch( + id="sandbox-switch", + value=False, + disabled=not self.sandbox_available, + ) + if not self.sandbox_available: + yield Label( + "bubblewrap not found — run `hqt doctor`", + id="sandbox-warning", + ) + yield Label("Filesystem access:") + yield Select( + [("Read-write", "rw"), ("Read-only", "ro")], + id="fs-select", + value="rw", + allow_blank=False, + ) + yield Label("Network:") + yield Switch(id="net-switch", value=True) with Horizontal(classes="dialog-actions"): yield Button("OK", variant="primary", id="ok-btn") yield Button("Cancel", id="cancel-btn") @@ -71,6 +100,13 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non # can be completed from the keyboard without reaching for the mouse. self._submit() + @staticmethod + def _policy_from(enabled: bool, fs: str, net: bool) -> SandboxPolicy | None: + """Pure mapping from widget values to a policy (None when disabled).""" + if not enabled: + return None + return SandboxPolicy(fs=fs, net=net) + def _submit(self) -> None: harness = self.query_one("#harness-select", Select).value nickname = self.query_one("#nickname-input", Input).value @@ -86,7 +122,12 @@ class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | Non branch_input.focus() return + enabled = self.query_one("#sandbox-switch", Switch).value + fs = self.query_one("#fs-select", Select).value + net = self.query_one("#net-switch", Switch).value + policy = self._policy_from(bool(enabled), str(fs), bool(net)) + if harness and harness != Select.BLANK: - self.dismiss((str(harness), nickname, model, worktree_branch)) + self.dismiss((str(harness), nickname, model, worktree_branch, policy)) else: self.dismiss(None) diff --git a/tests/test_tui.py b/tests/test_tui.py index d5ad978..fb50dc4 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -292,7 +292,7 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh(): call_order: list[str] = [] async def fake_create( - project_id, harness_name, nickname, model, worktree_branch=None + project_id, harness_name, nickname, model, worktree_branch=None, sandbox=None ): # Brief yield so that, if two separate workers were used, the list # worker would be able to start and record "list" before this @@ -324,7 +324,7 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh(): await pilot.pause() # The NewSessionScreen is now on top; dismiss it with a valid result tuple. - app.screen.dismiss(("claude", "mynick", None, None)) + app.screen.dismiss(("claude", "mynick", None, None, None)) await pilot.pause() # Wait for the worker spawned by on_dismiss to finish deterministically. @@ -396,7 +396,7 @@ async def test_new_session_on_dismiss_notifies_on_spawn_failure(): await app.workers.wait_for_complete() await pilot.pause() - app.screen.dismiss(("claude", "mynick", None, None)) + app.screen.dismiss(("claude", "mynick", None, None, None)) await pilot.pause() await app.workers.wait_for_complete() await pilot.pause() @@ -467,7 +467,7 @@ async def test_new_session_on_dismiss_no_notify_on_success(): await app.workers.wait_for_complete() await pilot.pause() - app.screen.dismiss(("claude", "mynick", None, None)) + app.screen.dismiss(("claude", "mynick", None, None, None)) await pilot.pause() await app.workers.wait_for_complete() await pilot.pause() @@ -582,7 +582,7 @@ async def test_new_session_enter_in_input_submits_dialog(): await pilot.press("enter") await pilot.pause() - assert results == [("claude", "mywork", None, None)], f"got {results}" + assert results == [("claude", "mywork", None, None, None)], f"got {results}" # --------------------------------------------------------------------------- @@ -1291,7 +1291,7 @@ async def test_new_session_worktree_unchecked_returns_none_branch(): app.screen.query_one("#nickname-input", Input).value = "mywork" app.screen.query_one("#ok-btn", Button).press() await pilot.pause() - assert results == [("claude", "mywork", None, None)], f"got {results}" + assert results == [("claude", "mywork", None, None, None)], f"got {results}" @pytest.mark.asyncio @@ -1316,7 +1316,7 @@ async def test_new_session_worktree_checked_uses_typed_branch(): branch.value = "feature-x" app.screen.query_one("#ok-btn", Button).press() await pilot.pause() - assert results == [("claude", "My Work", None, "feature-x")], f"got {results}" + assert results == [("claude", "My Work", None, "feature-x", None)], f"got {results}" @pytest.mark.asyncio @@ -1338,7 +1338,7 @@ async def test_new_session_worktree_checked_blank_branch_slugifies_nickname(): app.screen.query_one("#branch-input", Input).value = "" app.screen.query_one("#ok-btn", Button).press() await pilot.pause() - assert results == [("claude", "Cool Feature!", None, "cool-feature")], ( + assert results == [("claude", "Cool Feature!", None, "cool-feature", None)], ( f"got {results}" ) @@ -1504,3 +1504,46 @@ async def test_delete_non_worktree_session_does_not_push_modal(): verify_db = app._db_factory() assert verify_db.get(Session, sess_id) is None verify_db.close() + + +# --------------------------------------------------------------------------- +# Task 6: Sandbox controls in NewSessionScreen +# --------------------------------------------------------------------------- + + +def test_policy_from_disabled_returns_none(): + from hqt.tui.screens.new_session import NewSessionScreen + + assert NewSessionScreen._policy_from(False, "rw", True) is None + + +def test_policy_from_enabled_builds_policy(): + from hqt.sandbox import SandboxPolicy + from hqt.tui.screens.new_session import NewSessionScreen + + p = NewSessionScreen._policy_from(True, "ro", False) + assert p == SandboxPolicy(fs="ro", net=False) + + +@pytest.mark.asyncio +async def test_sandbox_switch_disabled_when_unavailable(): + from hqt.tui.screens.new_session import NewSessionScreen + from textual.widgets import Switch + + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + app.push_screen(NewSessionScreen(["claude"], is_git_repo=False, sandbox_available=False)) + await pilot.pause() + assert app.screen.query_one("#sandbox-switch", Switch).disabled is True + + +@pytest.mark.asyncio +async def test_sandbox_switch_enabled_when_available(): + from hqt.tui.screens.new_session import NewSessionScreen + from textual.widgets import Switch + + app = HqtApp() + async with app.run_test(size=(120, 40)) as pilot: + app.push_screen(NewSessionScreen(["claude"], is_git_repo=False, sandbox_available=True)) + await pilot.pause() + assert app.screen.query_one("#sandbox-switch", Switch).disabled is False