538 lines
18 KiB
Python
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
|