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
+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