570 lines
19 KiB
Python
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)]
|