feat: sandbox toggles in New Session dialog, gated on bwrap availability

Adds sandbox_available param to NewSessionScreen, renders Switch/Select/
Switch widgets for sandbox on/off, filesystem access (rw/ro), and network
toggle. When bwrap is unavailable the switch is disabled and a warning label
is shown. The _policy_from staticmethod maps widget values to SandboxPolicy.
The result tuple gains SandboxPolicy | None as its fifth element. app.py
passes sandbox_available=sandbox.is_available() when opening the dialog and
unpacks the new element to forward sandbox= to create_session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 21:33:34 -04:00
parent e54cbb96b6
commit f6ed53ce23
3 changed files with 108 additions and 15 deletions
+12 -3
View File
@@ -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())
+45 -4
View File
@@ -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)
+51 -8
View File
@@ -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