Merge pull request #61885 from embwl0x/agent/tui-secret-overlay-expiry

fix(tui): dismiss expired sensitive prompts
This commit is contained in:
brooklyn! 2026-07-12 05:50:24 -05:00 committed by GitHub
commit 04c7d104a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 174 additions and 22 deletions

View file

@ -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

View file

@ -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")

View file

@ -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
```
```

View file

@ -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)', () => {

View file

@ -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
})
})

View file

@ -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}`)

View file

@ -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<OverlayState, 'secret' | 'sudo'>,
rpc: GatewayRpc,
sys: (text: string) => void
) {
if (overlay.sudo) {
const requestId = overlay.sudo.requestId
patchOverlayState({ sudo: null })
sys('sudo cancelled')
return rpc<SudoRespondResponse>('sudo.respond', { password: '', request_id: requestId })
}
if (overlay.secret) {
const requestId = overlay.secret.requestId
patchOverlayState({ secret: null })
sys('secret entry cancelled')
return rpc<SecretRespondResponse>('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<SudoRespondResponse>('sudo.respond', { password: '', request_id: overlay.sudo.requestId })
.then(r => r && (patchOverlayState({ sudo: null }), actions.sys('sudo cancelled')))
}
if (overlay.secret) {
return gateway
.rpc<SecretRespondResponse>('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 })

View file

@ -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…' })
})

View file

@ -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' }

View file

@ -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