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:
+12
-3
@@ -10,6 +10,7 @@ from textual.screen import ModalScreen
|
|||||||
from textual.theme import Theme
|
from textual.theme import Theme
|
||||||
from textual.widgets import Footer
|
from textual.widgets import Footer
|
||||||
|
|
||||||
|
from hqt import sandbox
|
||||||
from hqt.config import get_settings
|
from hqt.config import get_settings
|
||||||
from hqt.git import worktree
|
from hqt.git import worktree
|
||||||
from hqt.db.engine import ensure_db, get_engine, get_session_factory
|
from hqt.db.engine import ensure_db, get_engine, get_session_factory
|
||||||
@@ -255,10 +256,10 @@ class HqtApp(App):
|
|||||||
project_path = project.path
|
project_path = project.path
|
||||||
|
|
||||||
def on_dismiss(
|
def on_dismiss(
|
||||||
result: tuple[str, str, str | None, str | None] | None,
|
result: tuple[str, str, str | None, str | None, object] | None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if result:
|
if result:
|
||||||
harness_name, nickname, model, worktree_branch = result
|
harness_name, nickname, model, worktree_branch, sandbox_policy = result
|
||||||
|
|
||||||
async def _do() -> None:
|
async def _do() -> None:
|
||||||
create_result = await self._sessions().create_session(
|
create_result = await self._sessions().create_session(
|
||||||
@@ -267,6 +268,7 @@ class HqtApp(App):
|
|||||||
nickname,
|
nickname,
|
||||||
model,
|
model,
|
||||||
worktree_branch=worktree_branch,
|
worktree_branch=worktree_branch,
|
||||||
|
sandbox=sandbox_policy,
|
||||||
)
|
)
|
||||||
if not create_result.spawn_ok:
|
if not create_result.spawn_ok:
|
||||||
first_line = next(
|
first_line = next(
|
||||||
@@ -296,7 +298,14 @@ class HqtApp(App):
|
|||||||
except Exception as exc: # noqa: BLE001 - keep the worker from crashing the TUI
|
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)
|
log.warning("is_git_repo check failed for %s: %s", project_path, exc)
|
||||||
is_git_repo = False
|
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())
|
self.run_worker(_open())
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
from textual.app import ComposeResult
|
from textual.app import ComposeResult
|
||||||
from textual.containers import Horizontal, Vertical
|
from textual.containers import Horizontal, Vertical
|
||||||
from textual.screen import ModalScreen
|
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.git import worktree
|
||||||
|
from hqt.sandbox import SandboxPolicy
|
||||||
|
|
||||||
|
|
||||||
class NewSessionScreen(ModalScreen[tuple[str, str, str | None, str | None] | None]):
|
class NewSessionScreen(
|
||||||
def __init__(self, harness_names: list[str], is_git_repo: bool) -> None:
|
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.harness_names = harness_names
|
||||||
self._is_git_repo = is_git_repo
|
self._is_git_repo = is_git_repo
|
||||||
|
self.sandbox_available = sandbox_available
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
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 = Input(placeholder="branch name", id="branch-input")
|
||||||
branch_input.display = False
|
branch_input.display = False
|
||||||
yield branch_input
|
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"):
|
with Horizontal(classes="dialog-actions"):
|
||||||
yield Button("OK", variant="primary", id="ok-btn")
|
yield Button("OK", variant="primary", id="ok-btn")
|
||||||
yield Button("Cancel", id="cancel-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.
|
# can be completed from the keyboard without reaching for the mouse.
|
||||||
self._submit()
|
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:
|
def _submit(self) -> None:
|
||||||
harness = self.query_one("#harness-select", Select).value
|
harness = self.query_one("#harness-select", Select).value
|
||||||
nickname = self.query_one("#nickname-input", Input).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()
|
branch_input.focus()
|
||||||
return
|
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:
|
if harness and harness != Select.BLANK:
|
||||||
self.dismiss((str(harness), nickname, model, worktree_branch))
|
self.dismiss((str(harness), nickname, model, worktree_branch, policy))
|
||||||
else:
|
else:
|
||||||
self.dismiss(None)
|
self.dismiss(None)
|
||||||
|
|||||||
+51
-8
@@ -292,7 +292,7 @@ async def test_new_session_on_dismiss_sequential_create_then_refresh():
|
|||||||
call_order: list[str] = []
|
call_order: list[str] = []
|
||||||
|
|
||||||
async def fake_create(
|
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
|
# Brief yield so that, if two separate workers were used, the list
|
||||||
# worker would be able to start and record "list" before this
|
# 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()
|
await pilot.pause()
|
||||||
|
|
||||||
# The NewSessionScreen is now on top; dismiss it with a valid result tuple.
|
# 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()
|
await pilot.pause()
|
||||||
|
|
||||||
# Wait for the worker spawned by on_dismiss to finish deterministically.
|
# 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 app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
app.screen.dismiss(("claude", "mynick", None, None))
|
app.screen.dismiss(("claude", "mynick", None, None, None))
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
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 app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
|
|
||||||
app.screen.dismiss(("claude", "mynick", None, None))
|
app.screen.dismiss(("claude", "mynick", None, None, None))
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
await app.workers.wait_for_complete()
|
await app.workers.wait_for_complete()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
@@ -582,7 +582,7 @@ async def test_new_session_enter_in_input_submits_dialog():
|
|||||||
await pilot.press("enter")
|
await pilot.press("enter")
|
||||||
await pilot.pause()
|
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("#nickname-input", Input).value = "mywork"
|
||||||
app.screen.query_one("#ok-btn", Button).press()
|
app.screen.query_one("#ok-btn", Button).press()
|
||||||
await pilot.pause()
|
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
|
@pytest.mark.asyncio
|
||||||
@@ -1316,7 +1316,7 @@ async def test_new_session_worktree_checked_uses_typed_branch():
|
|||||||
branch.value = "feature-x"
|
branch.value = "feature-x"
|
||||||
app.screen.query_one("#ok-btn", Button).press()
|
app.screen.query_one("#ok-btn", Button).press()
|
||||||
await pilot.pause()
|
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
|
@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("#branch-input", Input).value = ""
|
||||||
app.screen.query_one("#ok-btn", Button).press()
|
app.screen.query_one("#ok-btn", Button).press()
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert results == [("claude", "Cool Feature!", None, "cool-feature")], (
|
assert results == [("claude", "Cool Feature!", None, "cool-feature", None)], (
|
||||||
f"got {results}"
|
f"got {results}"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1504,3 +1504,46 @@ async def test_delete_non_worktree_session_does_not_push_modal():
|
|||||||
verify_db = app._db_factory()
|
verify_db = app._db_factory()
|
||||||
assert verify_db.get(Session, sess_id) is None
|
assert verify_db.get(Session, sess_id) is None
|
||||||
verify_db.close()
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user