chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,537 @@
|
||||
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
|
||||
Reference in New Issue
Block a user