fix: make sandboxed sessions usable for real git workflows

Address three integration gaps found in review:

- Bind ~/.gitconfig read-only in the sandbox base layer so `git commit`
  finds user.name/email inside the jail (spec §3).
- Bind a worktree session's repo common .git dir writable: the worktree's
  real gitdir lives at <repo>/.git/worktrees/<branch>, outside the bound
  cwd, so git was broken in sandboxed worktree sessions.
- doctor now gates on sandbox.is_available() (the single source of truth
  shared with the New Session dialog) rather than a raw shutil.which, so
  it agrees with the service on non-Linux platforms (spec §6).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 08:16:48 -04:00
parent 7d851dcb7e
commit 5f29418186
7 changed files with 130 additions and 26 deletions
+10 -5
View File
@@ -3,6 +3,7 @@ from unittest.mock import patch
from click.testing import CliRunner
from hqt import cli as cli_module
from hqt import sandbox
from hqt.cli import main
@@ -12,9 +13,11 @@ def _which(found: set[str]):
def test_doctor_reports_bwrap_present():
"""Test that doctor reports bubblewrap when present."""
# Patch the single shutil.which used by both cli and registry
with patch.object(
cli_module.shutil, "which", side_effect=_which({"tmux", "bwrap"})
# tmux is discovered via cli.shutil.which; bwrap availability comes from the
# shared sandbox.is_available() helper (the single source of truth).
with (
patch.object(cli_module.shutil, "which", side_effect=_which({"tmux", "bwrap"})),
patch.object(sandbox, "is_available", return_value=True),
):
result = CliRunner().invoke(main, ["doctor"])
assert result.exit_code == 0
@@ -23,8 +26,10 @@ def test_doctor_reports_bwrap_present():
def test_doctor_reports_bwrap_missing():
"""Test that doctor reports bubblewrap as missing when not present."""
# Patch the single shutil.which used by both cli and registry
with patch.object(cli_module.shutil, "which", side_effect=_which({"tmux"})):
with (
patch.object(cli_module.shutil, "which", side_effect=_which({"tmux"})),
patch.object(sandbox, "is_available", return_value=False),
):
result = CliRunner().invoke(main, ["doctor"])
assert result.exit_code == 0
assert "bubblewrap: ✗" in result.output
+19
View File
@@ -102,3 +102,22 @@ def test_wrap_skips_missing_binds(tmp_path):
["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [Bind(missing)]
)
assert str(missing) not in args
def test_wrap_binds_gitconfig_readonly_when_present(tmp_path, monkeypatch):
"""~/.gitconfig is bound read-only so `git commit` works inside the jail."""
home = tmp_path / "home"
home.mkdir()
gitconfig = home / ".gitconfig"
gitconfig.write_text("[user]\n\tname = Test\n")
monkeypatch.setattr(sandbox.Path, "home", classmethod(lambda cls: home))
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
assert (str(gitconfig), str(gitconfig)) in _split(args, "--ro-bind")
def test_wrap_skips_gitconfig_when_absent(tmp_path, monkeypatch):
home = tmp_path / "home"
home.mkdir()
monkeypatch.setattr(sandbox.Path, "home", classmethod(lambda cls: home))
args = sandbox.wrap(["sh"], tmp_path, SandboxPolicy(fs="rw", net=True), [])
assert str(home / ".gitconfig") not in args
+37 -7
View File
@@ -1547,10 +1547,15 @@ def sandbox_service(factory, db, tmux, sandbox_harness):
@pytest.mark.asyncio
async def test_sandboxed_create_wraps_command(sandbox_service, db, tmux, sandbox_harness):
with patch("hqt.sessions.service.sandbox.is_available", return_value=True), patch(
"hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"]
) as mock_wrap:
async def test_sandboxed_create_wraps_command(
sandbox_service, db, tmux, sandbox_harness
):
with (
patch("hqt.sessions.service.sandbox.is_available", return_value=True),
patch(
"hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"]
) as mock_wrap,
):
result = await sandbox_service.create_session(
project_id=1,
harness_name="claude-code",
@@ -1558,9 +1563,10 @@ async def test_sandboxed_create_wraps_command(sandbox_service, db, tmux, sandbox
)
mock_wrap.assert_called_once()
sandbox_harness["claude-code"].build_spawn_config.assert_called_once()
assert sandbox_harness["claude-code"].build_spawn_config.call_args.kwargs[
"sandboxed"
] is True
assert (
sandbox_harness["claude-code"].build_spawn_config.call_args.kwargs["sandboxed"]
is True
)
assert tmux.spawn.call_args.args[0].command == ["bwrap", "--", "claude"]
assert result.session.sandbox_json == '{"fs": "rw", "net": true}'
@@ -1582,3 +1588,27 @@ async def test_sandboxed_create_raises_when_bwrap_missing(sandbox_service, db):
harness_name="claude-code",
sandbox=SandboxPolicy(fs="rw", net=True),
)
@pytest.mark.asyncio
async def test_sandboxed_worktree_binds_repo_git_dir(
sandbox_service, db, tmux, sandbox_harness, fake_worktree
):
"""A sandboxed worktree session binds the repo's common .git writable so
git works inside the jail (the worktree's gitdir lives under <repo>/.git)."""
from hqt.sandbox import Bind
with (
patch("hqt.sessions.service.sandbox.is_available", return_value=True),
patch(
"hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"]
) as mock_wrap,
):
await sandbox_service.create_session(
project_id=1,
harness_name="claude-code",
worktree_branch="feature-x",
sandbox=SandboxPolicy(fs="rw", net=True),
)
binds = mock_wrap.call_args.args[3]
assert Bind(Path("/tmp/myproj/.git"), writable=True) in binds
+15 -4
View File
@@ -292,7 +292,12 @@ 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, sandbox=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
@@ -1316,7 +1321,9 @@ 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", None)], f"got {results}"
assert results == [("claude", "My Work", None, "feature-x", None)], (
f"got {results}"
)
@pytest.mark.asyncio
@@ -1532,7 +1539,9 @@ async def test_sandbox_switch_disabled_when_unavailable():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
app.push_screen(NewSessionScreen(["claude"], is_git_repo=False, sandbox_available=False))
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
@@ -1544,6 +1553,8 @@ async def test_sandbox_switch_enabled_when_available():
app = HqtApp()
async with app.run_test(size=(120, 40)) as pilot:
app.push_screen(NewSessionScreen(["claude"], is_git_repo=False, sandbox_available=True))
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