mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
Merge pull request #61885 from embwl0x/agent/tui-secret-overlay-expiry
fix(tui): dismiss expired sensitive prompts
This commit is contained in:
commit
04c7d104a7
10 changed files with 174 additions and 22 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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…' })
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue