The gateway asked the user questions (approval, clarify, sudo, secret,
vault, MCP setup, the desktop read/act bridges) by emitting a
`<x>.request` EVENT carrying a hand-minted request_id, blocking the
agent thread on a module dict keyed by that id, and exposing a paired
`<x>.respond` METHOD per kind — thirteen pairs, four registries
(`_pending`, `_answers`, `_batch_clarify`, `_EXPIRING_REQUESTS`) and a
per-kind reconnect snapshot (`pending_clarify` / `pending_approval`)
that only two of the thirteen kinds ever got. JSON-RPC already has the
primitive: the server sends a request frame with an id and the client
answers with a response frame bearing the same id.
`tui_gateway/server_requests.py` owns the one mechanism:
send() block the agent thread until the response frame
(`srq-<n>` ids; ints belong to the client)
send_async() fire-and-callback variant (bot relay)
cancel*() withdraw with ONE `request.cancel {id, method, reason}`
event (timeout / interrupt / process exit /
answered elsewhere) instead of per-kind *.expire
open_requests() the still-open frames, replayed by session.resume,
session.activate and session.events.since so a
reconnecting client re-renders every kind, not two
clarify.lock stays a real client→server RPC (locks one batch
answer early); locked answers merge into the final
set even when the closing response carries only the
tail the user answered last
A client that does not implement a method answers -32601 and the agent
fails fast (the old fixed-timeout "unavailable" probes for tour/preview
still work — a wire error IS an answer). Approval: the queue entry's
settle hook withdraws the request when `/approve` from another surface,
a timeout or an interrupt resolves it first, so no window keeps a dead
card. Compute-host children own their waits; the parent mirrors their
open frames for replay and relays `clarify.lock` + response frames.
Clients: `JsonRpcRequestChannel` gains `onRequest` (unhandled → -32601,
dedup by id) and `JsonRpcGatewayClient` re-delivers `open_requests`
from the replay result. Desktop gets `gateway-event/server-requests.ts`
(one handler per method, replacing the request branches of
`input-requests.ts` / `desktop-bridge.ts`) and a `store/server-requests`
registry so every answer site calls `respondToServerRequest(id, result)`
synchronously; the TUI gets `createServerRequestHandler.ts` +
`serverRequestStore.ts`. `gateway-events.json` now pins both halves
(events + server request methods); the two contract tests check both.
Live (real stdio gateway, real `clarify_callback` on the agent thread):
before, `clarify.request` event + `clarify.respond` RPC, batch final
answers lost ('' returned); after, `{"id":"srq-…","method":"clarify"}`
frame, `session.events.since.open_requests` replays it, response frame
`{"answer":"yes"}` reaches the agent, batch lock + final response
merge to `{"q0":"1","q1":"free text"}`.
59 lines
2.5 KiB
Python
59 lines
2.5 KiB
Python
"""Regression test for TUI approval-prompt credential redaction (#48456).
|
|
|
|
Follow-up to #50767, which redacted the chat-platform and SSE/API approval
|
|
transports. The TUI JSON-RPC transport is the third egress: three
|
|
`register_gateway_notify` callbacks in `tui_gateway/server.py` emit the raw
|
|
`approval_data` (with an unredacted `command`) to the TUI client. They now
|
|
route through the module-level `_emit_approval_request` helper, which redacts
|
|
`payload["command"]` via the shared `gateway.run._redact_approval_command` seam
|
|
before emitting.
|
|
"""
|
|
|
|
import inspect
|
|
|
|
import pytest
|
|
|
|
|
|
class TestTuiApprovalEmitRedaction:
|
|
@staticmethod
|
|
def _sent(monkeypatch):
|
|
"""Capture the ``approval`` server request frame ``_emit_approval_request`` sends."""
|
|
from tui_gateway import server as tui_server, server_requests
|
|
|
|
sent = {}
|
|
monkeypatch.setattr(server_requests, "send_async",
|
|
lambda method, sid, params, on_result: (sent.update(method=method, sid=sid, params=params),
|
|
lambda reason: None)[1])
|
|
monkeypatch.setattr(tui_server, "_sessions", {"sess-1": {"session_key": "key-1"}})
|
|
return tui_server, sent
|
|
|
|
def test_emit_approval_request_redacts_command_in_payload(self, monkeypatch):
|
|
tui_server, sent = self._sent(monkeypatch)
|
|
raw = "curl -H 'Authorization: token ghp_01...6789' https://api.github.com"
|
|
tui_server._emit_approval_request("sess-1", {"command": raw, "description": "x"})
|
|
|
|
assert sent["method"] == "approval" and sent["sid"] == "sess-1"
|
|
# credential removed, non-command field + command structure preserved
|
|
assert "ghp_01...6789" not in sent["params"]["command"]
|
|
assert sent["params"]["description"] == "x"
|
|
assert "github.com" in sent["params"]["command"]
|
|
|
|
@pytest.mark.parametrize(
|
|
("allow_session", "allow_permanent", "expected"),
|
|
[
|
|
(True, True, ["once", "session", "always", "deny"]),
|
|
(True, False, ["once", "session", "deny"]),
|
|
(False, False, ["once", "deny"]),
|
|
],
|
|
)
|
|
def test_emit_approval_request_honors_allowed_scopes(
|
|
self, monkeypatch, allow_session, allow_permanent, expected
|
|
):
|
|
tui_server, sent = self._sent(monkeypatch)
|
|
tui_server._emit_approval_request(
|
|
"sess-1",
|
|
{"allow_permanent": allow_permanent, "allow_session": allow_session, "command": "<write to AGENTS.md>"},
|
|
)
|
|
|
|
assert sent["params"]["choices"] == expected
|