Files
opensquilla--opensquilla/tests/test_tools/test_shell_process_isolation.py
T
2026-07-13 13:12:33 +08:00

538 lines
18 KiB
Python

from __future__ import annotations
import asyncio
import json
import os
import shlex
import subprocess
import sys
import time
from dataclasses import dataclass
from types import SimpleNamespace
import pytest
import structlog.testing
from opensquilla.sandbox.types import (
DenialReason,
DenialResult,
SandboxResult,
SecurityLevel,
SuggestedNextStep,
)
from opensquilla.tools.builtin import shell
from opensquilla.tools.types import CallerKind, ToolContext, ToolError, current_tool_context
def _python_shell_command(script: str) -> str:
argv = [sys.executable, "-c", script]
if os.name == "nt":
return subprocess.list2cmdline(argv)
return " ".join(shlex.quote(part) for part in argv)
class _FakeStdin:
def __init__(self) -> None:
self.closed = False
self.writes: list[bytes] = []
def is_closing(self) -> bool:
return self.closed
def write(self, data: bytes) -> None:
self.writes.append(data)
async def drain(self) -> None:
return None
def close(self) -> None:
self.closed = True
async def wait_closed(self) -> None:
return None
@dataclass
class _FakeProcess:
returncode: int | None = None
stdin: _FakeStdin | None = None
def __post_init__(self) -> None:
if self.stdin is None:
self.stdin = _FakeStdin()
def _ctx(
session_key: str,
*,
is_owner: bool = False,
agent_id: str = "agent",
caller_kind: CallerKind = CallerKind.AGENT,
) -> ToolContext:
return ToolContext(
is_owner=is_owner,
caller_kind=caller_kind,
session_key=session_key,
agent_id=agent_id,
)
def _process_test_python() -> str:
if os.path.exists("/usr/bin/python3"):
return "/usr/bin/python3"
return sys.executable
@pytest.fixture(autouse=True)
def _reset_bg_sessions():
previous = dict(shell._bg_sessions)
shell._bg_sessions.clear()
yield
shell._bg_sessions.clear()
shell._bg_sessions.update(previous)
def _session(
session_id: str,
session_key: str | None,
*,
agent_id: str | None = "agent",
done: bool = False,
command: str | None = None,
local_urls: list[str] | None = None,
) -> shell._BgSession:
return shell._BgSession(
session_id=session_id,
command=command or f"cmd {session_id}",
process=_FakeProcess(returncode=0 if done else None), # type: ignore[arg-type]
session_key=session_key,
agent_id=agent_id,
done=done,
returncode=0 if done else None,
local_urls=local_urls or [],
)
def test_background_process_result_surfaces_local_http_server_url() -> None:
session = _session(
"server",
"agent:main:one",
command="cd /workspace && python3 -m http.server 8080",
local_urls=shell._local_server_urls_from_command(
"cd /workspace && python3 -m http.server 8080"
),
)
result = shell._background_process_result(session)
assert "local_urls:" in result
assert "- http://127.0.0.1:8080/" in result
assert "include the local URL" in result
def test_bg_session_payload_surfaces_codetask_status_with_spaced_path(tmp_path) -> None:
run_dir = tmp_path / "Application Support" / "code-task" / "run-1"
run_dir.mkdir(parents=True)
status_path = run_dir / "status.json"
status_path.write_text(
json.dumps(
{
"run_id": "run-1",
"phase": "agent_running",
"updated": "2026-06-29T00:00:00Z",
"log_paths": {"stdout": str(run_dir / "agent_stdout.log")},
}
),
encoding="utf-8",
)
session = _session(
"code",
"agent:main:one",
command="opensquilla-gateway code-task solve --task-file /tmp/x --yes",
)
session.output_lines.append(
"[code-task] run started: run_id=run-1 "
f"artifact_dir={run_dir} status={status_path} "
"(work happens in the run dir)\n"
)
payload = shell._bg_session_payload(session)
code_task = payload["code_task"]
assert isinstance(code_task, dict)
assert code_task["run_id"] == "run-1"
assert code_task["artifact_dir"] == str(run_dir)
assert code_task["status_path"] == str(status_path)
assert code_task["phase"] == "agent_running"
assert code_task["log_paths"] == {"stdout": str(run_dir / "agent_stdout.log")}
@pytest.mark.skipif(os.name != "posix", reason="process group behavior is POSIX-specific")
@pytest.mark.asyncio
async def test_exec_command_returns_when_shell_exits_even_if_descendant_holds_pipe() -> None:
python = _process_test_python()
child_script = "import time; time.sleep(5)"
parent_script = (
"import subprocess, sys; "
"subprocess.Popen([sys.executable, '-c', "
f"{child_script!r}], stdout=sys.stdout, stderr=sys.stderr)"
)
command = f"{shlex.quote(python)} -c {shlex.quote(parent_script)}"
started = time.monotonic()
result = await shell.exec_command(command, timeout=1.0)
elapsed = time.monotonic() - started
assert result.startswith("exit_code=0\n")
assert elapsed < 1.0
@pytest.mark.skipif(os.name != "posix", reason="process group behavior is POSIX-specific")
@pytest.mark.asyncio
async def test_exec_command_cleans_descendant_after_shell_exits(tmp_path) -> None:
python = _process_test_python()
marker = tmp_path / "descendant-ran"
child_script = (
"import pathlib, time; "
f"time.sleep(0.5); pathlib.Path({str(marker)!r}).write_text('ran')"
)
parent_script = (
"import subprocess, sys; "
"subprocess.Popen([sys.executable, '-c', "
f"{child_script!r}])"
)
command = f"{shlex.quote(python)} -c {shlex.quote(parent_script)}"
result = await shell.exec_command(command, timeout=1.0)
await asyncio.sleep(0.8)
assert result.startswith("exit_code=0\n")
assert not marker.exists()
@pytest.mark.asyncio
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell quoting is required")
async def test_exec_command_timeout_still_stops_foreground_process() -> None:
python = _process_test_python()
command = f"{shlex.quote(python)} -c {shlex.quote('import time; time.sleep(5)')}"
started = time.monotonic()
result = await shell.exec_command(command, timeout=0.1)
elapsed = time.monotonic() - started
assert "[timeout after 0.1s]" in result
assert elapsed < 1.0
@pytest.mark.asyncio
async def test_exec_command_writes_optional_stdin() -> None:
command = _python_shell_command(
"import sys; data = sys.stdin.read(); print('STDIN:' + data)"
)
result = await shell.exec_command(command, stdin="payload", timeout=1.0)
exit_line, stdout = result.split("\n", 1)
assert exit_line == "exit_code=0"
assert stdout.splitlines() == ["STDIN:payload"]
@pytest.mark.asyncio
@pytest.mark.skipif(os.name != "posix", reason="large pipe backpressure is POSIX-specific")
async def test_exec_command_stdin_write_obeys_timeout() -> None:
command = _python_shell_command("import time; time.sleep(5)")
started = time.monotonic()
result = await shell.exec_command(command, stdin="x" * 1_000_000, timeout=0.2)
elapsed = time.monotonic() - started
assert "[timeout after 0.2s]" in result
assert elapsed < 3.0, "timeout path should return before the 5s child sleep"
@pytest.mark.asyncio
@pytest.mark.skipif(os.name != "posix", reason="POSIX shell quoting is required")
async def test_exec_command_sandbox_escalation_stdin_returns_when_shell_exits(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
runtime = SimpleNamespace(effective=SimpleNamespace(sandbox_enabled=True))
child_script = "import time; time.sleep(5)"
parent_script = (
"import subprocess, sys; "
"sys.stdin.read(); "
"subprocess.Popen([sys.executable, '-c', "
f"{child_script!r}], stdout=sys.stdout, stderr=sys.stderr)"
)
command = f"{shlex.quote(sys.executable)} -c {shlex.quote(parent_script)}"
async def fake_gate_action(**kwargs: object):
request = SimpleNamespace(
cwd=tmp_path,
action_kind="shell.exec",
policy=SimpleNamespace(),
reason="test",
)
return object(), SimpleNamespace(), request
async def fake_run_under_backend(*args: object, **kwargs: object) -> SandboxResult:
return SandboxResult(
returncode=1,
stdout="",
stderr="",
wall_time_s=0.0,
backend_used="seatbelt",
backend_notes=("sandbox denied",),
)
async def fake_escalate_backend_denial(*args: object, **kwargs: object) -> DenialResult:
return DenialResult(
reason=DenialReason.SEATBELT_DENIED,
suggested_next_step=SuggestedNextStep.ASK_USER,
level=SecurityLevel.STANDARD,
action_fingerprint="fp",
message="sandbox denied",
retryable=False,
)
monkeypatch.setattr(shell, "get_runtime", lambda: runtime)
monkeypatch.setattr(shell, "gate_action", fake_gate_action)
monkeypatch.setattr(shell, "run_under_backend", fake_run_under_backend)
monkeypatch.setattr(shell, "escalate_backend_denial", fake_escalate_backend_denial)
started = time.monotonic()
result = await shell.exec_command(command, stdin="payload", timeout=0.5)
elapsed = time.monotonic() - started
payload = json.loads(result)
assert payload["status"] == "denied"
assert payload["reason"] == "seatbelt_denied"
assert elapsed < 0.5
@pytest.mark.asyncio
async def test_process_list_filters_to_current_session_and_warns_for_untagged() -> None:
shell._bg_sessions["own"] = _session("own", "agent:main:one")
shell._bg_sessions["other"] = _session("other", "agent:main:two")
shell._bg_sessions["legacy"] = _session("legacy", None)
token = current_tool_context.set(_ctx("agent:main:one"))
try:
with structlog.testing.capture_logs() as captured:
payload = json.loads(await shell.process("list"))
finally:
current_tool_context.reset(token)
assert [session["session_id"] for session in payload["sessions"]] == ["own"]
assert any(event["event"] == "shell.bg_session_untagged" for event in captured)
@pytest.mark.asyncio
async def test_process_owner_context_can_list_all_sessions() -> None:
shell._bg_sessions["own"] = _session("own", "agent:main:one")
shell._bg_sessions["other"] = _session("other", "agent:main:two")
token = current_tool_context.set(
_ctx("agent:main:ops", is_owner=True, caller_kind=CallerKind.CLI)
)
try:
payload = json.loads(await shell.process("list"))
finally:
current_tool_context.reset(token)
assert {session["session_id"] for session in payload["sessions"]} == {"own", "other"}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"action", ["poll", "wait", "log", "kill", "remove", "write", "submit", "eof"]
)
async def test_process_cross_context_operations_are_denied(action: str) -> None:
shell._bg_sessions["owned-by-other"] = _session(
"owned-by-other",
"agent:main:other",
done=action == "remove",
)
token = current_tool_context.set(_ctx("agent:main:one"))
kwargs = {"data": "hello"} if action in {"write", "submit"} else {}
try:
with pytest.raises(ToolError, match="not accessible"):
await shell.process(action, session_id="owned-by-other", **kwargs)
finally:
current_tool_context.reset(token)
@pytest.mark.asyncio
async def test_process_owner_context_can_poll_other_sessions() -> None:
shell._bg_sessions["other"] = _session("other", "agent:main:two")
token = current_tool_context.set(
_ctx("agent:main:ops", is_owner=True, caller_kind=CallerKind.CLI)
)
try:
payload = json.loads(await shell.process("poll", session_id="other"))
finally:
current_tool_context.reset(token)
assert payload["status"] == "ok"
assert payload["session"]["session_id"] == "other"
@pytest.mark.asyncio
async def test_process_poll_includes_local_urls_for_server_sessions() -> None:
shell._bg_sessions["server"] = _session(
"server",
"agent:main:one",
command="python -m http.server 9090",
local_urls=["http://127.0.0.1:9090/"],
)
token = current_tool_context.set(_ctx("agent:main:one"))
try:
payload = json.loads(await shell.process("poll", session_id="server"))
finally:
current_tool_context.reset(token)
assert payload["session"]["local_urls"] == ["http://127.0.0.1:9090/"]
@pytest.mark.asyncio
async def test_process_subagent_owner_context_is_not_admin_bypass() -> None:
shell._bg_sessions["own"] = _session("own", "subagent:agent:main:one")
shell._bg_sessions["other"] = _session("other", "agent:main:two")
token = current_tool_context.set(
_ctx("subagent:agent:main:one", is_owner=True, caller_kind=CallerKind.SUBAGENT)
)
try:
payload = json.loads(await shell.process("list"))
with pytest.raises(ToolError, match="not accessible"):
await shell.process("poll", session_id="other")
finally:
current_tool_context.reset(token)
assert [session["session_id"] for session in payload["sessions"]] == ["own"]
# --- process(action="wait") - blocking await replaces poll loops (codex) ---
async def _real_bg_session(session_id: str, session_key: str, argv: list[str]):
proc = await asyncio.create_subprocess_exec(
*argv,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
sess = shell._BgSession(
session_id=session_id,
command=" ".join(argv),
process=proc,
session_key=session_key,
agent_id="agent",
)
shell._bg_sessions[session_id] = sess
return sess
def test_process_wait_is_a_valid_action() -> None:
assert "wait" in shell.PROCESS_ACTIONS
def test_process_tool_declares_wait_timeout_metadata() -> None:
# Without this metadata the 60s tool watchdog would kill a long wait.
from opensquilla.tools.registry import get_default_registry
spec = get_default_registry().get("process").spec
assert (
spec.execution_timeout_seconds
>= shell._MAX_PROCESS_WAIT_TIMEOUT + shell._PROCESS_WAIT_TIMEOUT_PADDING
)
assert spec.execution_timeout_argument == "timeout"
def test_process_wait_uses_coding_mode_default_timeout() -> None:
ctx = ToolContext(coding_mode=True)
assert ctx.coding_mode is True
token = current_tool_context.set(ctx)
try:
assert (
shell._resolve_process_wait_timeout(None)
== shell._CODING_PROCESS_WAIT_TIMEOUT
)
finally:
current_tool_context.reset(token)
@pytest.mark.skipif(os.name != "posix", reason="uses POSIX sleep/true")
@pytest.mark.asyncio
async def test_process_wait_blocks_until_exit() -> None:
await _real_bg_session("w1", "agent:main:one", ["sleep", "0.3"])
token = current_tool_context.set(_ctx("agent:main:one"))
try:
payload = json.loads(await shell.process("wait", session_id="w1"))
finally:
current_tool_context.reset(token)
assert payload["exited"] is True
assert payload["session"]["status"] == "done"
assert payload["session"]["returncode"] == 0
@pytest.mark.skipif(os.name != "posix", reason="uses POSIX true")
@pytest.mark.asyncio
async def test_process_wait_on_already_exited_returns_immediately() -> None:
sess = await _real_bg_session("w2", "agent:main:one", ["true"])
await sess.process.wait()
token = current_tool_context.set(_ctx("agent:main:one"))
try:
payload = json.loads(await shell.process("wait", session_id="w2"))
finally:
current_tool_context.reset(token)
assert payload["exited"] is True
assert payload["session"]["returncode"] == 0
@pytest.mark.skipif(os.name != "posix", reason="uses POSIX sleep")
@pytest.mark.asyncio
async def test_process_wait_timeout_keeps_running_and_not_timed_out() -> None:
sess = await _real_bg_session("w3", "agent:main:one", ["sleep", "5"])
token = current_tool_context.set(_ctx("agent:main:one"))
try:
payload = json.loads(await shell.process("wait", session_id="w3", timeout=0.2))
assert payload["exited"] is False
assert payload["session"]["status"] == "running"
# The wait-action timeout must NOT flip the process-lifetime timed_out flag.
assert payload["session"]["timed_out"] is False
finally:
sess.process.terminate()
try:
await asyncio.wait_for(sess.process.wait(), timeout=2)
except (TimeoutError, ProcessLookupError):
pass
current_tool_context.reset(token)
@pytest.mark.skipif(os.name != "posix", reason="uses POSIX sleep")
@pytest.mark.asyncio
async def test_process_wait_finalizes_when_exit_races_timeout(monkeypatch) -> None:
# The process exits right at the wait timeout boundary, where
# _wait_bg_process reports False. The wait action must still finalize and
# report a consistent (not stale "running") payload (codex review).
sess = await _real_bg_session("w4", "agent:main:one", ["sleep", "0.1"])
proc = sess.process
async def _wait_then_report_timeout(session, timeout):
await proc.wait() # the process actually exits...
return False # ...but we report a timeout (the boundary race)
monkeypatch.setattr(shell, "_wait_bg_process", _wait_then_report_timeout)
token = current_tool_context.set(_ctx("agent:main:one"))
try:
payload = json.loads(await shell.process("wait", session_id="w4", timeout=0.01))
finally:
current_tool_context.reset(token)
assert payload["exited"] is True
assert payload["session"]["status"] == "done"
assert payload["session"]["returncode"] == 0