diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 4f09af7aeabf..274ad8906be4 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -264,6 +264,48 @@ def test_block_and_respond(capture): assert result[0] == "my_answer" +@pytest.mark.parametrize("event", ["secret.request", "sudo.request"]) +def test_sensitive_prompt_timeout_emits_expiry(capture, event): + server, buf = capture + + assert server._block(event, "s1", {}, timeout=0) == "" + + messages = [json.loads(line) for line in buf.getvalue().splitlines()] + request, expiry = [message["params"] for message in messages] + assert request["type"] == event + assert expiry["type"] == event.removesuffix(".request") + ".expire" + assert expiry["session_id"] == "s1" + assert expiry["payload"]["request_id"] == request["payload"]["request_id"] + + +@pytest.mark.parametrize( + ("method", "value_key"), + [("secret.respond", "value"), ("sudo.respond", "password")], +) +def test_late_sensitive_prompt_response_is_idempotent(server, method, value_key): + response = server.handle_request( + { + "id": "late-response", + "method": method, + "params": {"request_id": "expired-request", value_key: ""}, + } + ) + + assert response["result"] == {"status": "expired"} + + +def test_late_clarify_response_remains_protocol_error(server): + response = server.handle_request( + { + "id": "late-clarify", + "method": "clarify.respond", + "params": {"request_id": "expired-request", "answer": ""}, + } + ) + + assert response["error"]["code"] == 4009 + + def test_clear_pending(server): ev = threading.Event() # _pending values are (sid, Event) tuples diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 192cb6c0f9fa..c0a3f3f4c47c 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -2035,15 +2035,26 @@ def _block(event: str, sid: str, payload: dict, timeout: int = 300) -> str: _pending[rid] = (sid, ev) payload["request_id"] = rid _pending_prompt_payloads[rid] = (event, dict(payload)) + answered = False + answer = "" + answer_present = False try: _emit(event, sid, payload) - ev.wait(timeout=timeout) + answered = ev.wait(timeout=timeout) finally: with _prompt_lock: _pending.pop(rid, None) _pending_prompt_payloads.pop(rid, None) - with _prompt_lock: - return _answers.pop(rid, "") + answer_present = rid in _answers + answer = _answers.pop(rid, "") + + if not answered and not answer_present and event in {"secret.request", "sudo.request"}: + _emit( + f"{event.removesuffix('.request')}.expire", + sid, + {"request_id": rid}, + ) + return answer def _clear_pending(sid: str | None = None) -> None: @@ -10163,11 +10174,13 @@ def _(rid, params: dict) -> dict: # ── Methods: respond ───────────────────────────────────────────────── -def _respond(rid, params, key): +def _respond(rid, params, key, *, allow_expired=False): r = params.get("request_id", "") with _prompt_lock: entry = _pending.get(r) if not entry: + if allow_expired and r: + return _ok(rid, {"status": "expired"}) return _err(rid, 4009, f"no pending {key} request") _, ev = entry _answers[r] = params.get(key, "") @@ -10188,12 +10201,12 @@ def _(rid, params: dict) -> dict: @method("sudo.respond") def _(rid, params: dict) -> dict: - return _respond(rid, params, "password") + return _respond(rid, params, "password", allow_expired=True) @method("secret.respond") def _(rid, params: dict) -> dict: - return _respond(rid, params, "value") + return _respond(rid, params, "value", allow_expired=True) @method("approval.respond") diff --git a/ui-tui/README.md b/ui-tui/README.md index 159db8293b61..fe5ab7c8db1d 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -287,7 +287,9 @@ Primary event types the client handles today: | `clarify.request` | `{ question, choices?, request_id }` | | `approval.request` | `{ command, description, allow_permanent? }` | | `sudo.request` | `{ request_id }` | +| `sudo.expire` | `{ request_id }` clears a timed-out sudo prompt | | `secret.request` | `{ prompt, env_var, request_id }` | +| `secret.expire` | `{ request_id }` clears a timed-out secret prompt | | `background.complete` | `{ task_id, text }` | | `billing.step_up.verification` | `{ verification_url, user_code }` | | `review.summary` | `{ text }` | @@ -487,4 +489,4 @@ tui_gateway/ server.py RPC handlers and session logic render.py optional rich/ANSI bridge slash_worker.py persistent HermesCLI subprocess for slash commands -``` \ No newline at end of file +``` diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index fad5f6b4564f..ba36ef6b4960 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -1307,6 +1307,24 @@ describe('createGatewayEventHandler', () => { expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false) }) + it('clears only the matching sensitive prompt when the gateway expires it', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + patchOverlayState({ + secret: { envVar: 'NEW_KEY', prompt: 'Enter new key', requestId: 'secret-new' }, + sudo: { requestId: 'sudo-1' } + }) + + onEvent({ payload: { request_id: 'secret-old' }, type: 'secret.expire' } as any) + expect(getOverlayState().secret?.requestId).toBe('secret-new') + + onEvent({ payload: { request_id: 'secret-new' }, type: 'secret.expire' } as any) + expect(getOverlayState().secret).toBeNull() + + onEvent({ payload: { request_id: 'sudo-1' }, type: 'sudo.expire' } as any) + expect(getOverlayState().sudo).toBeNull() + }) + // ── Credits notice (Strategy B) ────────────────────────────────────── describe('credits notice', () => { it('shows a notice immediately when idle (no turn in flight)', () => { diff --git a/ui-tui/src/__tests__/useInputHandlers.test.ts b/ui-tui/src/__tests__/useInputHandlers.test.ts index fa9372d5356e..ef9e676f73e1 100644 --- a/ui-tui/src/__tests__/useInputHandlers.test.ts +++ b/ui-tui/src/__tests__/useInputHandlers.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it, vi } from 'vitest' +import { getOverlayState, patchOverlayState, resetOverlayState } from '../app/overlayStore.js' import { applyVoiceRecordResponse, + dismissSensitivePrompt, handleIdleHotkeyExit, shouldAllowIdleHotkeyExit, shouldFallThroughForScroll @@ -112,3 +114,33 @@ describe('applyVoiceRecordResponse', () => { expect(setProcessing).toHaveBeenCalledWith(false) }) }) + +describe('dismissSensitivePrompt', () => { + it('clears a sudo overlay before a stale cancel RPC resolves', async () => { + resetOverlayState() + patchOverlayState({ sudo: { requestId: 'sudo-1' } }) + const rpc = vi.fn().mockResolvedValue(null) + const sys = vi.fn() + + const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys) + + expect(getOverlayState().sudo).toBeNull() + expect(sys).toHaveBeenCalledWith('sudo cancelled') + expect(rpc).toHaveBeenCalledWith('sudo.respond', { password: '', request_id: 'sudo-1' }) + await pending + }) + + it('clears a secret overlay before a stale cancel RPC resolves', async () => { + resetOverlayState() + patchOverlayState({ secret: { envVar: 'API_KEY', prompt: 'Enter API key', requestId: 'secret-1' } }) + const rpc = vi.fn().mockResolvedValue(null) + const sys = vi.fn() + + const pending = dismissSensitivePrompt(getOverlayState(), rpc, sys) + + expect(getOverlayState().secret).toBeNull() + expect(sys).toHaveBeenCalledWith('secret entry cancelled') + expect(rpc).toHaveBeenCalledWith('secret.respond', { request_id: 'secret-1', value: '' }) + await pending + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 5bb819790d4f..72cad059eb5c 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -807,6 +807,16 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return + case 'sudo.expire': + patchOverlayState(prev => (prev.sudo?.requestId === ev.payload.request_id ? { ...prev, sudo: null } : prev)) + + return + + case 'secret.expire': + patchOverlayState(prev => (prev.secret?.requestId === ev.payload.request_id ? { ...prev, secret: null } : prev)) + + return + case 'background.complete': dropBgTask(ev.payload.task_id) sys(`[bg ${ev.payload.task_id}] ${ev.payload.text}`) diff --git a/ui-tui/src/app/useInputHandlers.ts b/ui-tui/src/app/useInputHandlers.ts index 2f95c565e85a..3461cd241a88 100644 --- a/ui-tui/src/app/useInputHandlers.ts +++ b/ui-tui/src/app/useInputHandlers.ts @@ -16,7 +16,13 @@ import { computePrecisionWheelStep, initPrecisionWheel } from '../lib/precisionW import { computeWheelStep, initWheelAccelForHost } from '../lib/wheelAccel.js' import { getInputSelection } from './inputSelectionStore.js' -import type { InputHandlerActions, InputHandlerContext, InputHandlerResult } from './interfaces.js' +import type { + GatewayRpc, + InputHandlerActions, + InputHandlerContext, + InputHandlerResult, + OverlayState +} from './interfaces.js' import { $isBlocked, $overlayState, patchOverlayState } from './overlayStore.js' import { turnController } from './turnController.js' import { patchTurnState } from './turnStore.js' @@ -97,6 +103,30 @@ export function applyVoiceRecordResponse( } } +export function dismissSensitivePrompt( + overlay: Pick, + rpc: GatewayRpc, + sys: (text: string) => void +) { + if (overlay.sudo) { + const requestId = overlay.sudo.requestId + + patchOverlayState({ sudo: null }) + sys('sudo cancelled') + + return rpc('sudo.respond', { password: '', request_id: requestId }) + } + + if (overlay.secret) { + const requestId = overlay.secret.requestId + + patchOverlayState({ secret: null }) + sys('secret entry cancelled') + + return rpc('secret.respond', { request_id: requestId, value: '' }) + } +} + export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { const { actions, composer, gateway, terminal, voice, wheelStep } = ctx const { actions: cActions, refs: cRefs, state: cState } = composer @@ -149,16 +179,8 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { .then(r => r && (patchOverlayState({ approval: null }), patchTurnState({ outcome: 'denied' }))) } - if (overlay.sudo) { - return gateway - .rpc('sudo.respond', { password: '', request_id: overlay.sudo.requestId }) - .then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled'))) - } - - if (overlay.secret) { - return gateway - .rpc('secret.respond', { request_id: overlay.secret.requestId, value: '' }) - .then(r => r && (patchOverlayState({ secret: null }), actions.sys('secret entry cancelled'))) + if (overlay.sudo || overlay.secret) { + return dismissSensitivePrompt(overlay, gateway.rpc, actions.sys) } if (overlay.modelPicker) { @@ -373,7 +395,7 @@ export function useInputHandlers(ctx: InputHandlerContext): InputHandlerResult { return } - if (isCtrl(key, ch, 'c')) { + if (isCtrl(key, ch, 'c') || (key.escape && (overlay.secret || overlay.sudo))) { cancelOverlayFromCtrlC() } else if (key.escape && overlay.sessions) { patchOverlayState({ sessions: false }) diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index 761f4a851afb..a7b5e2d2e4ee 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -911,7 +911,13 @@ export function useMainApp(gw: GatewayClient) { return } - return respondWith('sudo.respond', { password: pw, request_id: overlay.sudo.requestId }, () => { + const requestId = overlay.sudo.requestId + + if (!pw) { + patchOverlayState({ sudo: null }) + } + + return respondWith('sudo.respond', { password: pw, request_id: requestId }, () => { patchOverlayState({ sudo: null }) patchUiState({ status: 'running…' }) }) @@ -925,7 +931,13 @@ export function useMainApp(gw: GatewayClient) { return } - return respondWith('secret.respond', { request_id: overlay.secret.requestId, value }, () => { + const requestId = overlay.secret.requestId + + if (!value) { + patchOverlayState({ secret: null }) + } + + return respondWith('secret.respond', { request_id: requestId, value }, () => { patchOverlayState({ secret: null }) patchUiState({ status: 'running…' }) }) diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 6eb0317fc2f7..58f1aec7da83 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -698,6 +698,7 @@ export type GatewayEvent = } | { payload: { request_id: string }; session_id?: string; type: 'sudo.request' } | { payload: { env_var: string; prompt: string; request_id: string }; session_id?: string; type: 'secret.request' } + | { payload: { request_id: string }; session_id?: string; type: 'secret.expire' | 'sudo.expire' } | { payload: { task_id: string; text: string }; session_id?: string; type: 'background.complete' } | { payload?: { text?: string }; session_id?: string; type: 'review.summary' } | { payload: SubagentEventPayload; session_id?: string; type: 'subagent.spawn_requested' } diff --git a/website/docs/developer-guide/programmatic-integration.md b/website/docs/developer-guide/programmatic-integration.md index d21edbf85c38..16963bdd08f9 100644 --- a/website/docs/developer-guide/programmatic-integration.md +++ b/website/docs/developer-guide/programmatic-integration.md @@ -57,7 +57,7 @@ terminal.resize clipboard.paste image.attach ### Events streamed back -`message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `secret.request`, `gateway.ready`, plus session lifecycle and error events. +`message.delta`, `message.complete`, `tool.start`, `tool.progress`, `tool.complete`, `approval.request`, `clarify.request`, `sudo.request`, `sudo.expire`, `secret.request`, `secret.expire`, `gateway.ready`, plus session lifecycle and error events. Expiry events carry the original `{ request_id }`; external hosts should clear only the matching pending prompt. ### Pi-style RPC mapping