Files
2026-07-13 13:12:33 +08:00

570 lines
19 KiB
Python

from __future__ import annotations
import asyncio
import json
from typing import Any
import pytest
from opensquilla.cli.tui.adapters.approvals import (
ApprovalChoice,
decide_from_response,
deny_decision,
parse_approval_envelope,
tui_approval_handler,
)
from opensquilla.engine.commands import Surface
class _RecordingRenderer:
"""Minimal async renderer capturing status lines."""
def __init__(self, output_handle: Any | None = None, *, expose_handle: bool = True) -> None:
if expose_handle:
self.output_handle = output_handle
self.statuses: list[tuple[str, str]] = []
async def astatus(self, message: str, *, style: str = "dim") -> None:
self.statuses.append((message, style))
class _HeadlessRenderer:
"""Renderer with no output handle attribute at all (replay/eval shape)."""
def __init__(self) -> None:
self.statuses: list[tuple[str, str]] = []
async def astatus(self, message: str, *, style: str = "dim") -> None:
self.statuses.append((message, style))
class _HostOutputHandle:
"""Output handle exposing the OpenTUI approval round-trip."""
def __init__(self, response: Any) -> None:
self._response = response
self.requests: list[dict[str, object]] = []
async def request_approval(self, request: dict[str, object]) -> Any:
self.requests.append(request)
if isinstance(self._response, Exception):
raise self._response
return self._response
class _WrappedHandle:
"""Plugin-wrapper shape: request_approval always callable, flag tells truth."""
def __init__(self, *, supports: bool) -> None:
self.supports_request_approval = supports
self.requests: list[dict[str, object]] = []
async def request_approval(self, request: dict[str, object]) -> Any:
self.requests.append(request)
return None
async def write_through(self, payload: str) -> None:
return None
class _NativeOutputHandle:
"""Native terminal handle shape: write-through only, no host IPC."""
async def write_through(self, payload: str) -> None:
return None
class _FakeConsole:
def __init__(self, answers: list[str] | None = None, *, raise_eof: bool = False) -> None:
self._answers = list(answers or [])
self._raise_eof = raise_eof
self.printed: list[str] = []
self.prompts: list[str] = []
def print(self, message: str) -> None:
self.printed.append(message)
def input(self, prompt: str) -> str:
self.prompts.append(prompt)
if self._raise_eof or not self._answers:
raise EOFError
return self._answers.pop(0)
class _FakeResponse:
def __init__(self, approved: bool, choice: str | None = None) -> None:
self.approved = approved
self.choice = choice
class _Resolver:
def __init__(self, error: Exception | None = None) -> None:
self.calls: list[tuple[str, bool, str | None]] = []
self._error = error
async def __call__(
self,
approval_id: str,
approved: bool,
*,
choice: str | None = None,
) -> None:
self.calls.append((approval_id, approved, choice))
if self._error is not None:
raise self._error
def _sandbox_envelope_payload() -> dict[str, Any]:
return {
"status": "approval_required",
"approval_id": "appr-1",
"message": "Network access needs approval.",
"approvalKind": "sandbox_network",
"host": "packages.example.test",
"choices": [
{"id": "allow_once", "label": "Allow once", "approved": True, "style": "primary"},
{"id": "allow_same_type", "label": "Allow same type", "approved": True},
{"id": "deny", "label": "Deny", "approved": False, "style": "danger"},
],
}
def _exec_envelope_payload() -> dict[str, Any]:
return {
"status": "approval_required",
"approval_id": "appr-2",
"tool": "shell",
"command": "touch demo.txt",
"message": "This command needs approval.",
}
# ---------------------------------------------------------------------------
# Envelope parsing
# ---------------------------------------------------------------------------
def test_parse_approval_envelope_from_dict_and_json_string() -> None:
payload = _sandbox_envelope_payload()
from_dict = parse_approval_envelope(payload)
from_string = parse_approval_envelope(json.dumps(payload))
assert from_dict == from_string
assert from_dict is not None
assert from_dict.status == "approval_required"
assert from_dict.approval_id == "appr-1"
assert from_dict.tool == "sandbox_network"
assert from_dict.summary == "network access to packages.example.test"
assert from_dict.choices == (
ApprovalChoice(id="allow_once", label="Allow once", approved=True),
ApprovalChoice(id="allow_same_type", label="Allow same type", approved=True),
ApprovalChoice(id="deny", label="Deny", approved=False),
)
assert from_dict.actionable
def test_parse_approval_envelope_exec_command_summary() -> None:
envelope = parse_approval_envelope(_exec_envelope_payload())
assert envelope is not None
assert envelope.tool == "shell"
assert envelope.summary == "touch demo.txt"
def test_parse_approval_envelope_blocked_and_pending() -> None:
blocked = parse_approval_envelope(
{
"status": "blocked",
"reason": "workspace_write_deny",
"tool": "write_file",
"path": "src/protected.py",
"message": "write_file blocked by workspace write deny policy.",
}
)
assert blocked is not None
assert blocked.status == "blocked"
assert not blocked.actionable
pending = parse_approval_envelope(
{"status": "approval_pending", "approval_id": "appr-9", "command": "ls"}
)
assert pending is not None
assert pending.actionable
@pytest.mark.parametrize(
"result",
[
None,
42,
["not", "an", "envelope"],
{"status": "success", "output": "ok"},
{"status": "approval_denied", "approval_id": "appr-1"},
"plain tool output",
"{not valid json",
'"a json string, not an object"',
json.dumps({"status": "error", "message": "boom"}),
],
)
def test_parse_approval_envelope_rejects_non_approval_results(result: Any) -> None:
assert parse_approval_envelope(result) is None
def test_decide_from_response_maps_choices_authoritatively() -> None:
envelope = parse_approval_envelope(_sandbox_envelope_payload())
assert envelope is not None
# A named choice wins, and its approved flag is authoritative.
assert decide_from_response(envelope, approved=True, choice="deny") == (False, "deny")
# A bare approve maps to the first approving choice.
assert decide_from_response(envelope, approved=True, choice=None) == (True, "allow_once")
# A bare deny maps to the denying choice.
assert decide_from_response(envelope, approved=False, choice=None) == (False, "deny")
# Unknown choices fall back to polarity matching.
assert decide_from_response(envelope, approved=False, choice="bogus") == (False, "deny")
assert deny_decision(envelope) == (False, "deny")
def test_decide_from_response_without_choices_uses_boolean() -> None:
envelope = parse_approval_envelope(_exec_envelope_payload())
assert envelope is not None
assert decide_from_response(envelope, approved=True, choice=None) == (True, None)
assert decide_from_response(envelope, approved=False, choice=None) == (False, None)
# ---------------------------------------------------------------------------
# Handler behaviour
# ---------------------------------------------------------------------------
async def test_handler_is_noop_for_non_approval_results() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _RecordingRenderer(_HostOutputHandle(_FakeResponse(True)))
await handler({"status": "success", "output": "done"}, renderer, resolver)
await handler("plain text result", renderer, resolver, surface=Surface.CLI_GATEWAY)
await handler(None, renderer, resolver, elevated_state={"mode": None})
assert resolver.calls == []
assert renderer.statuses == []
assert renderer.output_handle.requests == []
async def test_handler_renders_blocked_results_without_resolving() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _RecordingRenderer(_HostOutputHandle(_FakeResponse(True)))
await handler(
{
"status": "blocked",
"tool": "write_file",
"message": "write blocked by policy",
},
renderer,
resolver,
)
assert resolver.calls == []
assert renderer.output_handle.requests == []
assert len(renderer.statuses) == 1
message, _style = renderer.statuses[0]
assert "blocked" in message
assert "write_file" in message
async def test_handler_skips_resolution_when_envelope_has_no_id() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _RecordingRenderer(_HostOutputHandle(_FakeResponse(True)))
await handler(
{"status": "approval_pending", "approval_id": "", "command": "ls"},
renderer,
resolver,
)
assert resolver.calls == []
assert renderer.output_handle.requests == []
assert len(renderer.statuses) == 1
async def test_handler_routes_through_host_overlay_and_resolves_approval() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
handle = _HostOutputHandle(_FakeResponse(True, choice=None))
renderer = _RecordingRenderer(handle)
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert handle.requests == [
{
"id": "appr-1",
"tool": "sandbox_network",
"summary": "network access to packages.example.test",
"choices": ["allow_once", "allow_same_type", "deny"],
}
]
assert resolver.calls == [("appr-1", True, "allow_once")]
async def test_handler_host_choice_response_is_authoritative() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
handle = _HostOutputHandle(_FakeResponse(True, choice="deny"))
renderer = _RecordingRenderer(handle)
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", False, "deny")]
async def test_handler_denies_on_host_timeout() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
# request_approval returns None on timeout/teardown — the handler must deny.
handle = _HostOutputHandle(None)
renderer = _RecordingRenderer(handle)
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", False, "deny")]
async def test_handler_denies_on_dead_bridge() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
handle = _HostOutputHandle(RuntimeError("bridge is gone"))
renderer = _RecordingRenderer(handle)
await handler(_exec_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-2", False, None)]
async def test_handler_survives_resolver_failure() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver(error=RuntimeError("rpc down"))
renderer = _RecordingRenderer(_HostOutputHandle(_FakeResponse(True)))
await handler(_exec_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-2", True, None)]
assert any("failed to resolve" in message for message, _style in renderer.statuses)
async def test_handler_ignores_wrapped_handle_without_host_capability() -> None:
"""Plugin wrappers always expose a callable request_approval; the handler
must honour the capability flag and fall back to the console prompt."""
console = _FakeConsole(answers=["y"])
handler = tui_approval_handler(output_console=console)
resolver = _Resolver()
handle = _WrappedHandle(supports=False)
renderer = _RecordingRenderer(handle)
await handler(_exec_envelope_payload(), renderer, resolver)
assert handle.requests == []
assert resolver.calls == [("appr-2", True, None)]
assert console.prompts == ["Approve? [y/N]: "]
async def test_native_console_prompt_approves_and_denies() -> None:
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
approve_console = _FakeConsole(answers=["y"])
await tui_approval_handler(output_console=approve_console)(
_exec_envelope_payload(), renderer, resolver
)
deny_console = _FakeConsole(answers=["n"])
await tui_approval_handler(output_console=deny_console)(
_exec_envelope_payload(), renderer, resolver
)
default_console = _FakeConsole(answers=[""])
await tui_approval_handler(output_console=default_console)(
_exec_envelope_payload(), renderer, resolver
)
assert resolver.calls == [
("appr-2", True, None),
("appr-2", False, None),
("appr-2", False, None), # bare Enter must never approve
]
assert any("approval required: shell" in line for line in approve_console.printed)
assert any("touch demo.txt" in line for line in approve_console.printed)
async def test_native_console_prompt_supports_numbered_choices() -> None:
console = _FakeConsole(answers=["2"])
handler = tui_approval_handler(output_console=console)
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", True, "allow_same_type")]
assert console.prompts == ["Approve? [y/N/1-3]: "]
assert any("1) Allow once" in line for line in console.printed)
async def test_native_console_prompt_denies_on_eof() -> None:
console = _FakeConsole(raise_eof=True)
handler = tui_approval_handler(output_console=console)
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", False, "deny")]
async def test_native_console_prompt_denies_on_out_of_range_choice() -> None:
console = _FakeConsole(answers=["9"])
handler = tui_approval_handler(output_console=console)
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
await handler(_sandbox_envelope_payload(), renderer, resolver)
assert resolver.calls == [("appr-1", False, "deny")]
async def test_cancelled_console_prompt_denies_and_reaps_the_reader() -> None:
"""Cancelling the turn mid-prompt must deny the pending approval and wait
for the in-flight reader before propagating, so the runtime's next stdin
reader never races an orphaned one for the user's next line."""
reader_started = asyncio.Event()
release_reader = asyncio.Event()
reader_finished = asyncio.Event()
async def blocking_reader(prompt: str) -> str:
reader_started.set()
try:
await release_reader.wait()
finally:
reader_finished.set()
return "y"
handler = tui_approval_handler(
output_console=_FakeConsole(), prompt_reader=blocking_reader
)
resolver = _Resolver()
renderer = _RecordingRenderer(_NativeOutputHandle())
turn = asyncio.create_task(handler(_exec_envelope_payload(), renderer, resolver))
await reader_started.wait()
turn.cancel()
# The cancelled handler stays alive until the reader is reaped.
done, _pending = await asyncio.wait({turn}, timeout=0.05)
assert not done
assert resolver.calls == [("appr-2", False, None)]
release_reader.set()
with pytest.raises(asyncio.CancelledError):
await turn
assert reader_finished.is_set()
# The reaped reader's late answer is discarded, never re-resolved.
assert resolver.calls == [("appr-2", False, None)]
async def test_cancelled_console_prompt_reaps_reader_even_when_deny_rpc_fails() -> None:
release_reader = asyncio.Event()
async def blocking_reader(prompt: str) -> str:
await release_reader.wait()
return "y"
handler = tui_approval_handler(
output_console=_FakeConsole(), prompt_reader=blocking_reader
)
resolver = _Resolver(error=RuntimeError("rpc down"))
renderer = _RecordingRenderer(_NativeOutputHandle())
turn = asyncio.create_task(handler(_exec_envelope_payload(), renderer, resolver))
await asyncio.sleep(0)
turn.cancel()
done, _pending = await asyncio.wait({turn}, timeout=0.05)
assert not done
assert resolver.calls == [("appr-2", False, None)]
release_reader.set()
with pytest.raises(asyncio.CancelledError):
await turn
async def test_headless_renderer_gets_notice_and_no_resolution() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _HeadlessRenderer()
await handler(_exec_envelope_payload(), renderer, resolver)
assert resolver.calls == []
assert len(renderer.statuses) == 1
message, _style = renderer.statuses[0]
assert "approval required" in message
async def test_renderer_with_none_output_handle_gets_notice() -> None:
handler = tui_approval_handler(output_console=_FakeConsole())
resolver = _Resolver()
renderer = _RecordingRenderer(None)
await handler(_exec_envelope_payload(), renderer, resolver)
assert resolver.calls == []
assert len(renderer.statuses) == 1
# ---------------------------------------------------------------------------
# Default wiring
# ---------------------------------------------------------------------------
def test_default_turn_stream_dependencies_wires_interactive_handler() -> None:
from opensquilla.cli.tui.adapters import turn_stream_defaults
deps = turn_stream_defaults.default_turn_stream_dependencies()
assert deps.approval_handler is not turn_stream_defaults._noop_approval_handler
assert deps.approval_handler.__module__ == "opensquilla.cli.tui.adapters.approvals"
def test_default_turn_stream_dependencies_keeps_explicit_handler() -> None:
from opensquilla.cli.tui.adapters import turn_stream_defaults
async def explicit_handler(*_args: Any, **_kwargs: Any) -> None:
return None
deps = turn_stream_defaults.default_turn_stream_dependencies(
approval_handler=explicit_handler
)
assert deps.approval_handler is explicit_handler
async def test_default_handler_resolves_through_host_capable_renderer() -> None:
"""End-to-end over the default wiring: an approval envelope reaching the
handler via a host-capable renderer is presented and resolved."""
from opensquilla.cli.tui.adapters import turn_stream_defaults
deps = turn_stream_defaults.default_turn_stream_dependencies()
resolver = _Resolver()
handle = _HostOutputHandle(_FakeResponse(True, choice=None))
renderer = _RecordingRenderer(handle)
await deps.approval_handler(
json.dumps(_exec_envelope_payload()),
renderer,
resolver,
elevated_state=None,
surface=Surface.CLI_GATEWAY,
)
assert [request["id"] for request in handle.requests] == ["appr-2"]
assert resolver.calls == [("appr-2", True, None)]