chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,290 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from agents.sandbox.errors import PtySessionNotFoundError
|
||||
from agents.sandbox.manifest import Manifest
|
||||
from agents.sandbox.sandboxes.unix_local import (
|
||||
UnixLocalSandboxClient,
|
||||
UnixLocalSandboxSession,
|
||||
UnixLocalSandboxSessionState,
|
||||
_UnixPtyProcessEntry,
|
||||
)
|
||||
from agents.sandbox.snapshot import NoopSnapshot
|
||||
from agents.sandbox.types import ExecResult, User
|
||||
|
||||
|
||||
class _RecordingUnixLocalSession(UnixLocalSandboxSession):
|
||||
def __init__(self, root: Path) -> None:
|
||||
super().__init__(
|
||||
state=UnixLocalSandboxSessionState(
|
||||
manifest=Manifest(root=str(root)),
|
||||
snapshot=NoopSnapshot(id="noop"),
|
||||
)
|
||||
)
|
||||
self.exec_commands: list[tuple[str, ...]] = []
|
||||
|
||||
async def _exec_internal(
|
||||
self,
|
||||
*command: str | Path,
|
||||
timeout: float | None = None,
|
||||
) -> ExecResult:
|
||||
_ = timeout
|
||||
self.exec_commands.append(tuple(str(part) for part in command))
|
||||
return ExecResult(stdout=b"", stderr=b"", exit_code=0)
|
||||
|
||||
|
||||
class TestUnixLocalPty:
|
||||
@pytest.mark.asyncio
|
||||
async def test_tty_fd_close_is_owned_without_blocking_termination(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session = _RecordingUnixLocalSession(tmp_path)
|
||||
close_started = asyncio.Event()
|
||||
release_close = asyncio.Event()
|
||||
|
||||
async def blocked_to_thread(*args: object, **kwargs: object) -> None:
|
||||
_ = (args, kwargs)
|
||||
close_started.set()
|
||||
await release_close.wait()
|
||||
|
||||
monkeypatch.setattr(asyncio, "to_thread", blocked_to_thread)
|
||||
process = cast(
|
||||
asyncio.subprocess.Process,
|
||||
SimpleNamespace(returncode=0, pid=None),
|
||||
)
|
||||
entry = _UnixPtyProcessEntry(process=process, tty=True, primary_fd=123)
|
||||
|
||||
await asyncio.wait_for(session._terminate_pty_entry(entry), timeout=0.5)
|
||||
await close_started.wait()
|
||||
|
||||
assert len(session._fd_close_tasks) == 1
|
||||
await asyncio.wait_for(session._after_stop(), timeout=0.5)
|
||||
assert len(session._fd_close_tasks) == 1
|
||||
|
||||
release_close.set()
|
||||
await asyncio.gather(*session._fd_close_tasks)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert session._fd_close_tasks == set()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pty_exec_write_poll_and_unknown_session_errors(self, tmp_path: Path) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
|
||||
async with await client.create(manifest=manifest, snapshot=None, options=None) as session:
|
||||
started = await session.pty_exec_start(
|
||||
"sh",
|
||||
"-c",
|
||||
"IFS= read -r line; printf '%s\\n' \"$line\"",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.05,
|
||||
)
|
||||
|
||||
assert started.process_id is not None
|
||||
assert started.exit_code is None
|
||||
|
||||
written = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars="hello from pty\n",
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
assert written.process_id is None
|
||||
assert written.exit_code == 0
|
||||
assert "hello from pty" in written.output.decode("utf-8", errors="replace")
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="")
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=999_999, chars="")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pty_ctrl_c_interrupts_long_running_process(self, tmp_path: Path) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
|
||||
async with await client.create(manifest=manifest, snapshot=None, options=None) as session:
|
||||
started = await session.pty_exec_start(
|
||||
"sleep",
|
||||
"30",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.05,
|
||||
)
|
||||
|
||||
assert started.process_id is not None
|
||||
assert started.exit_code is None
|
||||
|
||||
first_interrupt = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars="\x03",
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
if first_interrupt.process_id is None:
|
||||
interrupted = first_interrupt
|
||||
else:
|
||||
interrupted = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars="",
|
||||
yield_time_s=5.5,
|
||||
)
|
||||
|
||||
assert interrupted.process_id is None
|
||||
assert interrupted.exit_code is not None
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="")
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("signum", "chars"),
|
||||
[
|
||||
pytest.param(signal.SIGINT, "\x03", id="sigint"),
|
||||
pytest.param(signal.SIGQUIT, "\x1c", id="sigquit"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_pty_terminal_signals_interrupt_even_if_parent_ignores_signal(
|
||||
self, tmp_path: Path, signum: signal.Signals, chars: str
|
||||
) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
previous_handler = signal.getsignal(signum)
|
||||
|
||||
signal.signal(signum, signal.SIG_IGN)
|
||||
try:
|
||||
async with await client.create(
|
||||
manifest=manifest, snapshot=None, options=None
|
||||
) as session:
|
||||
started = await session.pty_exec_start(
|
||||
"sleep",
|
||||
"30",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.05,
|
||||
)
|
||||
assert started.process_id is not None
|
||||
|
||||
interrupted = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars=chars,
|
||||
yield_time_s=5.5,
|
||||
)
|
||||
|
||||
assert interrupted.process_id is None
|
||||
assert interrupted.exit_code == -signum
|
||||
finally:
|
||||
signal.signal(signum, previous_handler)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_tty_pty_session_rejects_stdin_and_can_still_be_polled(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
|
||||
async with await client.create(manifest=manifest, snapshot=None, options=None) as session:
|
||||
started = await session.pty_exec_start(
|
||||
"sh",
|
||||
"-c",
|
||||
"printf 'stdout\\n'; printf 'stderr\\n' >&2; sleep 1",
|
||||
shell=False,
|
||||
tty=False,
|
||||
yield_time_s=0.05,
|
||||
)
|
||||
|
||||
assert started.process_id is not None
|
||||
assert started.exit_code is None
|
||||
started_text = started.output.decode("utf-8", errors="replace")
|
||||
assert "stdout" in started_text
|
||||
assert "stderr" in started_text
|
||||
|
||||
with pytest.raises(RuntimeError, match="stdin is not available for this process"):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="hello")
|
||||
|
||||
finished = await session.pty_write_stdin(
|
||||
session_id=started.process_id,
|
||||
chars="",
|
||||
yield_time_s=5.5,
|
||||
)
|
||||
text = finished.output.decode("utf-8", errors="replace")
|
||||
assert finished.process_id is None
|
||||
assert finished.exit_code == 0
|
||||
assert text == ""
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_terminates_active_pty_sessions(self, tmp_path: Path) -> None:
|
||||
client = UnixLocalSandboxClient()
|
||||
manifest = Manifest(root=str(tmp_path / "workspace"))
|
||||
|
||||
session = await client.create(manifest=manifest, snapshot=None, options=None)
|
||||
await session.start()
|
||||
started = await session.pty_exec_start(
|
||||
"sh",
|
||||
"-c",
|
||||
"printf 'ready\\n'; sleep 30",
|
||||
shell=False,
|
||||
tty=True,
|
||||
yield_time_s=0.25,
|
||||
)
|
||||
|
||||
assert started.process_id is not None
|
||||
assert "ready" in started.output.decode("utf-8", errors="replace")
|
||||
|
||||
await session.stop()
|
||||
|
||||
with pytest.raises(PtySessionNotFoundError):
|
||||
await session.pty_write_stdin(session_id=started.process_id, chars="")
|
||||
|
||||
|
||||
class TestUnixLocalUserScopedFilesystem:
|
||||
@pytest.mark.asyncio
|
||||
async def test_mkdir_as_user_checks_permissions_then_uses_local_fs(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
session = _RecordingUnixLocalSession(workspace)
|
||||
|
||||
await session.mkdir("nested", user=User(name="sandbox-user"))
|
||||
|
||||
assert (workspace / "nested").is_dir()
|
||||
assert len(session.exec_commands) == 1
|
||||
assert session.exec_commands[0][:4] == ("sudo", "-u", "sandbox-user", "--")
|
||||
assert session.exec_commands[0][4:6] == ("sh", "-lc")
|
||||
assert session.exec_commands[0][-2:] == (str(workspace / "nested"), "0")
|
||||
assert not any(part.startswith("mkdir ") for part in session.exec_commands[0])
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rm_as_user_checks_permissions_then_uses_local_fs(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
target = workspace / "stale.txt"
|
||||
target.write_text("stale", encoding="utf-8")
|
||||
session = _RecordingUnixLocalSession(workspace)
|
||||
|
||||
await session.rm("stale.txt", user=User(name="sandbox-user"))
|
||||
|
||||
assert not target.exists()
|
||||
assert len(session.exec_commands) == 1
|
||||
assert session.exec_commands[0][:4] == ("sudo", "-u", "sandbox-user", "--")
|
||||
assert session.exec_commands[0][4:6] == ("sh", "-lc")
|
||||
assert session.exec_commands[0][-2:] == (str(target), "0")
|
||||
assert not any(part.startswith("rm ") for part in session.exec_commands[0])
|
||||
Reference in New Issue
Block a user