Merge branch 'sandboxed-sessions'
This commit is contained in:
+95
-1
@@ -9,6 +9,7 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from hqt.db.models import Base, Harness, Project, ScheduledPrompt, Session
|
||||
from hqt.errors import ServiceError
|
||||
from hqt.sandbox import SandboxPolicy
|
||||
from hqt.sessions.service import SessionInfo, SessionService
|
||||
from hqt.tmux.manager import SpawnResult, TmuxManager
|
||||
|
||||
@@ -931,7 +932,7 @@ async def test_attach_late_capture_updates_id_before_resume(
|
||||
|
||||
assert ok is True
|
||||
harness.build_resume_config.assert_called_with(
|
||||
Path("/tmp/myproj"), "late-codex-id", result.session.model
|
||||
Path("/tmp/myproj"), "late-codex-id", result.session.model, sandboxed=False
|
||||
)
|
||||
from hqt.db.models import Session as DBSession
|
||||
|
||||
@@ -1646,3 +1647,96 @@ async def test_create_session_no_lock_for_non_capturing_harness(service, db, tmu
|
||||
# The `service` fixture's harness has captures_session_id = False, so the
|
||||
# spawn must NOT be serialized under the capture lock.
|
||||
assert observed["locked_during_spawn"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Task 5: sandbox wrapping on spawn and resume
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sandbox_harness():
|
||||
h = MagicMock()
|
||||
h.captures_session_id = False
|
||||
h.generate_session_id.return_value = "sess-1"
|
||||
h.build_spawn_config.return_value = MagicMock(
|
||||
command=["claude", "--dangerously-skip-permissions"],
|
||||
env={},
|
||||
cwd=Path("/tmp/myproj"),
|
||||
)
|
||||
h.sandbox_binds.return_value = []
|
||||
h.parse_status = MagicMock(return_value=None)
|
||||
return {"claude-code": h}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sandbox_service(factory, db, tmux, sandbox_harness):
|
||||
return SessionService(factory=factory, tmux=tmux, harnesses=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,
|
||||
):
|
||||
result = await sandbox_service.create_session(
|
||||
project_id=1,
|
||||
harness_name="claude-code",
|
||||
sandbox=SandboxPolicy(fs="rw", net=True),
|
||||
)
|
||||
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 tmux.spawn.call_args.args[0].command == ["bwrap", "--", "claude"]
|
||||
assert result.session.sandbox_json == '{"fs": "rw", "net": true}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unsandboxed_create_does_not_wrap(service, db, tmux, harnesses):
|
||||
with patch("hqt.sessions.service.sandbox.wrap") as mock_wrap:
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
mock_wrap.assert_not_called()
|
||||
assert result.session.sandbox_json is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sandboxed_create_raises_when_bwrap_missing(sandbox_service, db):
|
||||
with patch("hqt.sessions.service.sandbox.is_available", return_value=False):
|
||||
with pytest.raises(ServiceError, match="bubblewrap"):
|
||||
await sandbox_service.create_session(
|
||||
project_id=1,
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user