d80a288a6f
* feat(kiro-native): surface TUI approvals in Chat Signed-off-by: Michael Gardner <gardnmi@gmail.com> * chore: remove Kiro elicitation plan from PR Signed-off-by: Michael Gardner <gardnmi@gmail.com> * fix(kiro-native): harden permission mirror per review Address review findings on the Kiro permission mirror: - Reap finished web-delivery tasks from the pending map each poll, so a completed *or failed* keystroke delivery frees the single-prompt slot. Previously a failed delivery left the slot occupied forever, silently blocking every later prompt from reaching the web mirror. - Re-validate the visible prompt's focus and title for `accept` after the pre-Enter settle delay (symmetric with the decline path), so a focus or title drift during the settle window fails closed instead of pressing Enter on the wrong row. - Drop the redundant `event.request_id in pending` skip clause (subsumed by the `or pending` guard). - Correct docs/kiro-native-elicitation.md: cancelling a parked task only reliably aborts a verdict still waiting on the web user; a mid-delivery keystroke worker cannot be interrupted, and the per-keypress focus/title re-validation is what prevents a stray verdict from landing on a later prompt. Also document the one-at-a-time / Terminal-only fallback. Adds regression tests for the reaping behavior and the accept re-validation. Co-authored-by: Isaac * fix(test): use a benign completion token in kiro elicitation e2e The approve-path e2e asked Kiro to echo a `kiro-approval-<hex>` token right after a tool-approval prompt. A safety-conscious model reads "reply with this exact token" in an approval context as an attempt to emit a spoofed tool-approval signal and declines, so the turn-complete assertion failed even though the card -> approve -> Kiro-continues loop succeeded. Use a neutral `kiro-pwd-done-<hex>` token and plain framing, matching the render-parity sibling's benign-token pattern. Co-authored-by: Isaac * fix(kiro-native): truncate the title in the elicitation message content_preview was already capped at _PREVIEW_MAX but the card message interpolated the full untruncated title, so untrusted Kiro-derived text could reach the card unbounded. Reuse the truncated preview for both, matching the doc's untrusted-input handling. Co-authored-by: Isaac * fix(test): prove kiro approval continuation structurally, not via token echo Renaming the completion token was not enough: a safety-conscious model refuses the whole pattern of "after the approved command, output this exact token," reading it as an attempt to forge an approval signal, and runs the command but declines to emit the token. Drop the token entirely and assert continuation structurally instead -- after web approve, the gate releases, an assistant reply renders, and the turn finishes (no lingering working indicator). This no longer depends on model compliance or a machine-specific command output. Co-authored-by: Isaac * docs(kiro-native): document the single-slot reaper in race handling The race-handling section described the one-at-a-time slot but not the mechanism that frees it. Note that the slot is released when the delivery task finishes (delivered, failed checks, or timed out), not only on a recorder response, so a stuck verdict cannot wedge the slot for the session. Co-authored-by: Isaac --------- Signed-off-by: Michael Gardner <gardnmi@gmail.com> Co-authored-by: Dhruv Gupta <dhruv.gupta@databricks.com> Co-authored-by: Pat Sukprasert <pattara.sk127@gmail.com>
491 lines
17 KiB
Python
491 lines
17 KiB
Python
"""Tests for the Kiro-native ACP permission mirror."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import contextlib
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from omnigent import kiro_native_permissions as knp
|
|
from omnigent.kiro_native_bridge import acp_record_path
|
|
from omnigent.kiro_native_permissions import (
|
|
kiro_permission_elicitation_id,
|
|
parse_permission_request,
|
|
)
|
|
|
|
|
|
def _permission_msg(
|
|
request_id: str = "req-1",
|
|
*,
|
|
allow_option_id: str = "allow_once",
|
|
reject_option_id: str = "reject_once",
|
|
) -> dict:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"method": "session/request_permission",
|
|
"params": {
|
|
"sessionId": "kiro-session",
|
|
"toolCall": {"toolCallId": f"tool-{request_id}", "title": "Running: pwd"},
|
|
"options": [
|
|
{"optionId": allow_option_id, "name": "Yes", "kind": "allow_once"},
|
|
{"optionId": "allow_always", "name": "Always", "kind": "allow_always"},
|
|
{"optionId": reject_option_id, "name": "No", "kind": "reject_once"},
|
|
],
|
|
"_meta": {"trustOptions": True},
|
|
},
|
|
}
|
|
|
|
|
|
def _permission_result_msg(request_id: str = "req-1", option_id: str = "allow_once") -> dict:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"result": {"outcome": {"outcome": "selected", "optionId": option_id}},
|
|
}
|
|
|
|
|
|
def _record(message: dict, *, direction: str = "out") -> dict:
|
|
return {"ts": "2026-06-25T00:00:00Z", "dir": direction, "msg": json.dumps(message)}
|
|
|
|
|
|
def _record_bytes(message: dict, *, direction: str = "out") -> bytes:
|
|
return (json.dumps(_record(message, direction=direction)) + "\n").encode("utf-8")
|
|
|
|
|
|
def test_parse_permission_request_extracts_one_time_options() -> None:
|
|
req = parse_permission_request(_permission_msg())
|
|
|
|
assert req is not None
|
|
assert req.request_id == "req-1"
|
|
assert req.tool_call_id == "tool-req-1"
|
|
assert req.title == "Running: pwd"
|
|
assert req.accept_option_id == "allow_once"
|
|
assert req.decline_option_id == "reject_once"
|
|
assert req.preview == "Running: pwd"
|
|
|
|
|
|
def test_parse_permission_request_preserves_option_ids_by_kind() -> None:
|
|
req = parse_permission_request(
|
|
_permission_msg("req-1", allow_option_id="yes-1", reject_option_id="no-1")
|
|
)
|
|
|
|
assert req is not None
|
|
assert req.accept_option_id == "yes-1"
|
|
assert req.decline_option_id == "no-1"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"message",
|
|
[
|
|
pytest.param({"method": "session/prompt"}, id="not-permission"),
|
|
pytest.param({**_permission_msg(), "id": ""}, id="missing-id"),
|
|
pytest.param(
|
|
{
|
|
**_permission_msg(),
|
|
"params": {**_permission_msg()["params"], "toolCall": {"title": "Running: pwd"}},
|
|
},
|
|
id="missing-tool-call-id",
|
|
),
|
|
pytest.param(
|
|
{
|
|
**_permission_msg(),
|
|
"params": {
|
|
**_permission_msg()["params"],
|
|
"toolCall": {"toolCallId": "tool-1"},
|
|
},
|
|
},
|
|
id="missing-title",
|
|
),
|
|
pytest.param(
|
|
{**_permission_msg(), "params": {**_permission_msg()["params"], "options": []}},
|
|
id="missing-one-time-options",
|
|
),
|
|
],
|
|
)
|
|
def test_parse_permission_request_returns_none_for_unsupported_shapes(message: dict) -> None:
|
|
assert parse_permission_request(message) is None
|
|
|
|
|
|
def test_permission_result_request_id_extraction() -> None:
|
|
assert knp._permission_result_request_id(_permission_result_msg("req-9")) == "req-9"
|
|
assert knp._permission_result_request_id({"id": "req-9", "result": {}}) is None
|
|
assert knp._permission_result_request_id({"id": 1, "result": {"outcome": {}}}) is None
|
|
|
|
|
|
def test_elicitation_id_is_deterministic_and_session_scoped() -> None:
|
|
eid = kiro_permission_elicitation_id("conv_abc", "req-1")
|
|
assert eid == kiro_permission_elicitation_id("conv_abc", "req-1")
|
|
assert eid != kiro_permission_elicitation_id("conv_other", "req-1")
|
|
assert eid.startswith("elicit_kiro_conv_abc_")
|
|
|
|
|
|
def test_read_new_permission_events_incremental_and_partial_line(tmp_path: Path) -> None:
|
|
record_file = tmp_path / "kiro_acp_record.jsonl"
|
|
record_file.write_bytes(_record_bytes(_permission_msg("req-1")))
|
|
|
|
events, offset = knp._read_new_permission_events(record_file, 0)
|
|
|
|
assert [(event.kind, event.request_id) for event in events] == [("request", "req-1")]
|
|
|
|
with record_file.open("ab") as handle:
|
|
handle.write(_record_bytes(_permission_result_msg("req-1"), direction="in"))
|
|
handle.write(b'{"dir":"out","msg":"')
|
|
|
|
events2, offset2 = knp._read_new_permission_events(record_file, offset)
|
|
|
|
assert [(event.kind, event.request_id) for event in events2] == [("response", "req-1")]
|
|
assert offset2 == offset + len(_record_bytes(_permission_result_msg("req-1"), direction="in"))
|
|
|
|
|
|
def test_read_new_permission_events_ignores_malformed_and_non_permission(tmp_path: Path) -> None:
|
|
record_file = tmp_path / "kiro_acp_record.jsonl"
|
|
record_file.write_bytes(
|
|
b"not-json\n"
|
|
+ _record_bytes({"jsonrpc": "2.0", "method": "session/prompt"})
|
|
+ _record_bytes(_permission_msg("req-1"))
|
|
)
|
|
|
|
events, _offset = knp._read_new_permission_events(record_file, 0)
|
|
|
|
assert [(event.kind, event.request_id) for event in events] == [("request", "req-1")]
|
|
|
|
|
|
class _QueueClient:
|
|
"""Async httpx-client stub: records POSTs, returns queued responses."""
|
|
|
|
def __init__(self, responses: list[httpx.Response]) -> None:
|
|
self.posts: list[tuple[str, dict]] = []
|
|
self._responses = list(responses)
|
|
|
|
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
|
|
self.posts.append((url, json))
|
|
return self._responses.pop(0)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("response", "expected_action"),
|
|
[
|
|
pytest.param(httpx.Response(200, json={"action": "accept"}), "accept", id="accept"),
|
|
pytest.param(httpx.Response(200, json={"action": "decline"}), "decline", id="decline"),
|
|
pytest.param(httpx.Response(200, json={"action": "cancel"}), "cancel", id="cancel"),
|
|
pytest.param(httpx.Response(200), None, id="empty-200"),
|
|
pytest.param(httpx.Response(400, text="nope"), None, id="rejected"),
|
|
pytest.param(httpx.Response(200, content=b"not-json"), None, id="non-json"),
|
|
],
|
|
)
|
|
@pytest.mark.asyncio
|
|
async def test_run_one_permission_posts_then_delivers_verdict(
|
|
response: httpx.Response,
|
|
expected_action: str | None,
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
delivered: list[tuple[Path, str]] = []
|
|
|
|
def _fake_send(bridge_dir: Path, *, action: str, expected_title: str | None = None) -> None:
|
|
assert expected_title == "Running: pwd"
|
|
delivered.append((bridge_dir, action))
|
|
|
|
monkeypatch.setattr(knp, "send_kiro_permission_verdict", _fake_send)
|
|
req = parse_permission_request(_permission_msg("req-1"))
|
|
assert req is not None
|
|
client = _QueueClient([response])
|
|
|
|
await knp._run_one_permission(
|
|
client, # type: ignore[arg-type]
|
|
session_id="conv_1",
|
|
bridge_dir=tmp_path,
|
|
permission=req,
|
|
elicitation_id="elic_1",
|
|
)
|
|
|
|
url, body = client.posts[0]
|
|
assert url == "/v1/sessions/conv_1/hooks/native-permission-request"
|
|
assert body == {
|
|
"elicitation_id": "elic_1",
|
|
"agent": "Kiro",
|
|
"policy_name": "kiro_native_permission",
|
|
"operation_type": "tool",
|
|
"message": "Kiro wants approval for Running: pwd",
|
|
"content_preview": "Running: pwd",
|
|
}
|
|
if expected_action is None:
|
|
assert delivered == []
|
|
else:
|
|
assert delivered == [(tmp_path, expected_action)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_post_external_elicitation_resolved_shape() -> None:
|
|
client = _QueueClient([httpx.Response(200)])
|
|
await knp._post_external_elicitation_resolved(client, "conv_2", "elic_9") # type: ignore[arg-type]
|
|
assert client.posts == [
|
|
(
|
|
"/v1/sessions/conv_2/events",
|
|
{"type": "external_elicitation_resolved", "data": {"elicitation_id": "elic_9"}},
|
|
)
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supervise_mirror_parks_then_releases_on_permission_response(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
created: list[object] = []
|
|
|
|
class _FakeAsyncClient:
|
|
def __init__(self, **_kw: object) -> None:
|
|
self.posts: list[tuple[str, dict]] = []
|
|
created.append(self)
|
|
|
|
async def __aenter__(self) -> _FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_args: object) -> bool:
|
|
return False
|
|
|
|
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
|
|
self.posts.append((url, json))
|
|
return httpx.Response(200, request=httpx.Request("POST", url))
|
|
|
|
monkeypatch.setattr(knp.httpx, "AsyncClient", _FakeAsyncClient)
|
|
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
cancelled = asyncio.Event()
|
|
|
|
async def _fake_run_one(_client: object, **_kw: object) -> None:
|
|
started.set()
|
|
try:
|
|
await release.wait()
|
|
except asyncio.CancelledError:
|
|
cancelled.set()
|
|
raise
|
|
|
|
monkeypatch.setattr(knp, "_run_one_permission", _fake_run_one)
|
|
record_file = acp_record_path(tmp_path)
|
|
record_file.write_bytes(b"")
|
|
|
|
task = asyncio.create_task(
|
|
knp.supervise_kiro_permission_mirror(
|
|
base_url="http://t",
|
|
headers={},
|
|
session_id="conv_3",
|
|
bridge_dir=tmp_path,
|
|
poll_interval_s=0.001,
|
|
)
|
|
)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
with record_file.open("ab") as handle:
|
|
handle.write(_record_bytes(_permission_msg("req-1")))
|
|
await asyncio.wait_for(started.wait(), 2.0)
|
|
with record_file.open("ab") as handle:
|
|
handle.write(_record_bytes(_permission_result_msg("req-1"), direction="in"))
|
|
for _ in range(400):
|
|
if created and getattr(created[0], "posts", None):
|
|
break
|
|
await asyncio.sleep(0.005)
|
|
assert created
|
|
url, body = created[0].posts[0] # type: ignore[attr-defined]
|
|
assert url == "/v1/sessions/conv_3/events"
|
|
assert body["type"] == "external_elicitation_resolved"
|
|
assert body["data"]["elicitation_id"] == kiro_permission_elicitation_id("conv_3", "req-1")
|
|
await asyncio.wait_for(cancelled.wait(), 2.0)
|
|
finally:
|
|
release.set()
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supervise_mirror_skips_request_resolved_in_same_poll_batch(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
created: list[object] = []
|
|
|
|
class _FakeAsyncClient:
|
|
def __init__(self, **_kw: object) -> None:
|
|
self.posts: list[tuple[str, dict]] = []
|
|
created.append(self)
|
|
|
|
async def __aenter__(self) -> _FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_args: object) -> bool:
|
|
return False
|
|
|
|
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
|
|
self.posts.append((url, json))
|
|
return httpx.Response(200, request=httpx.Request("POST", url))
|
|
|
|
monkeypatch.setattr(knp.httpx, "AsyncClient", _FakeAsyncClient)
|
|
run_one_calls: list[str] = []
|
|
|
|
async def _fake_run_one(_client: object, *, permission: object, **_kw: object) -> None:
|
|
run_one_calls.append(permission.request_id) # type: ignore[attr-defined]
|
|
|
|
monkeypatch.setattr(knp, "_run_one_permission", _fake_run_one)
|
|
record_file = acp_record_path(tmp_path)
|
|
record_file.write_bytes(b"")
|
|
|
|
task = asyncio.create_task(
|
|
knp.supervise_kiro_permission_mirror(
|
|
base_url="http://t",
|
|
headers={},
|
|
session_id="conv_4",
|
|
bridge_dir=tmp_path,
|
|
poll_interval_s=0.02,
|
|
)
|
|
)
|
|
try:
|
|
await asyncio.sleep(0.08)
|
|
with record_file.open("ab") as handle:
|
|
handle.write(
|
|
_record_bytes(_permission_msg("req-1"))
|
|
+ _record_bytes(_permission_result_msg("req-1"), direction="in")
|
|
)
|
|
await asyncio.sleep(0.2)
|
|
assert run_one_calls == []
|
|
assert created
|
|
assert created[0].posts == [] # type: ignore[attr-defined]
|
|
finally:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supervise_mirror_skips_additional_request_while_one_is_pending(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
class _FakeAsyncClient:
|
|
def __init__(self, **_kw: object) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> _FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_args: object) -> bool:
|
|
return False
|
|
|
|
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
|
|
return httpx.Response(200, request=httpx.Request("POST", url))
|
|
|
|
monkeypatch.setattr(knp.httpx, "AsyncClient", _FakeAsyncClient)
|
|
started = asyncio.Event()
|
|
release = asyncio.Event()
|
|
run_one_calls: list[str] = []
|
|
|
|
async def _fake_run_one(_client: object, *, permission: object, **_kw: object) -> None:
|
|
run_one_calls.append(permission.request_id) # type: ignore[attr-defined]
|
|
started.set()
|
|
await release.wait()
|
|
|
|
monkeypatch.setattr(knp, "_run_one_permission", _fake_run_one)
|
|
record_file = acp_record_path(tmp_path)
|
|
record_file.write_bytes(b"")
|
|
|
|
task = asyncio.create_task(
|
|
knp.supervise_kiro_permission_mirror(
|
|
base_url="http://t",
|
|
headers={},
|
|
session_id="conv_5",
|
|
bridge_dir=tmp_path,
|
|
poll_interval_s=0.001,
|
|
)
|
|
)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
with record_file.open("ab") as handle:
|
|
handle.write(_record_bytes(_permission_msg("req-1")))
|
|
await asyncio.wait_for(started.wait(), 2.0)
|
|
with record_file.open("ab") as handle:
|
|
handle.write(_record_bytes(_permission_msg("req-2")))
|
|
await asyncio.sleep(0.05)
|
|
assert run_one_calls == ["req-1"]
|
|
finally:
|
|
release.set()
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_supervise_mirror_reaps_finished_task_and_mirrors_next_request(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A finished delivery task frees the slot so a later prompt is still mirrored.
|
|
|
|
Without reaping done tasks, a completed (or failed) web-delivery would keep
|
|
the single-prompt slot occupied forever and silently block every later
|
|
prompt from the web mirror.
|
|
"""
|
|
|
|
class _FakeAsyncClient:
|
|
def __init__(self, **_kw: object) -> None:
|
|
pass
|
|
|
|
async def __aenter__(self) -> _FakeAsyncClient:
|
|
return self
|
|
|
|
async def __aexit__(self, *_args: object) -> bool:
|
|
return False
|
|
|
|
async def post(self, url: str, *, json: dict, **_kw: object) -> httpx.Response:
|
|
return httpx.Response(200, request=httpx.Request("POST", url))
|
|
|
|
monkeypatch.setattr(knp.httpx, "AsyncClient", _FakeAsyncClient)
|
|
run_one_calls: list[str] = []
|
|
|
|
async def _fake_run_one(_client: object, *, permission: object, **_kw: object) -> None:
|
|
# Returns immediately, so the parked task finishes without a recorder
|
|
# response event ever arriving (mimics a delivered/failed verdict).
|
|
run_one_calls.append(permission.request_id) # type: ignore[attr-defined]
|
|
|
|
monkeypatch.setattr(knp, "_run_one_permission", _fake_run_one)
|
|
record_file = acp_record_path(tmp_path)
|
|
record_file.write_bytes(b"")
|
|
|
|
task = asyncio.create_task(
|
|
knp.supervise_kiro_permission_mirror(
|
|
base_url="http://t",
|
|
headers={},
|
|
session_id="conv_6",
|
|
bridge_dir=tmp_path,
|
|
poll_interval_s=0.001,
|
|
)
|
|
)
|
|
try:
|
|
await asyncio.sleep(0.05)
|
|
with record_file.open("ab") as handle:
|
|
handle.write(_record_bytes(_permission_msg("req-1")))
|
|
for _ in range(400):
|
|
if run_one_calls == ["req-1"]:
|
|
break
|
|
await asyncio.sleep(0.005)
|
|
assert run_one_calls == ["req-1"]
|
|
# No response event for req-1 ever arrives; the reaper must still free
|
|
# the slot once its task is done so req-2 gets mirrored.
|
|
with record_file.open("ab") as handle:
|
|
handle.write(_record_bytes(_permission_msg("req-2")))
|
|
for _ in range(400):
|
|
if run_one_calls == ["req-1", "req-2"]:
|
|
break
|
|
await asyncio.sleep(0.005)
|
|
assert run_one_calls == ["req-1", "req-2"]
|
|
finally:
|
|
task.cancel()
|
|
with contextlib.suppress(asyncio.CancelledError):
|
|
await task
|