Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions apps/desktop/src/app/session/hooks/use-session-actions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
import { $pinnedSessionIds } from '@/store/layout'
import { clearNotifications, notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { setApprovalRequest } from '@/store/prompts'
import {

Check failure on line 18 in apps/desktop/src/app/session/hooks/use-session-actions/index.ts

View workflow job for this annotation

GitHub Actions / JS & TS checks / apps/desktop / check:lint

Expected "@/store/projects" to come before "@/store/prompts"
beginSessionMutation,
endSessionMutation,
resolveNewSessionCwd,
Expand Down Expand Up @@ -192,6 +193,24 @@
workspaceTarget?: NewChatWorkspaceTarget
}

function restorePendingApproval(response: SessionResumeResponse, sessionId: string): boolean {
const pending = response.pending_approval

if (!pending) {
return false
}

setApprovalRequest({
allowPermanent: pending.allow_permanent !== false,
choices: pending.choices,
command: pending.command ?? '',
description: pending.description ?? 'dangerous command',
sessionId,
smartDenied: pending.smart_denied === true
})
return true
}

function normalizeNewChatWorkspaceTarget(target: NewChatWorkspaceTarget): NewChatWorkspaceTarget {
return typeof target === 'string' ? target.trim() || null : target
}
Expand Down Expand Up @@ -767,6 +786,7 @@
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} else {
const pendingApproval = restorePendingApproval(activated, cachedRuntimeId)
const runtimeInfo = applyRuntimeInfo(activated.info)

// `omit_messages` means the response carries NO transcript, not
Expand Down Expand Up @@ -817,6 +837,7 @@
messages: activatedMessages,
busy: running,
awaitingResponse: running,
needsInput: pendingApproval || state.needsInput,
// Adopting someone else's turn: we'll stream its reply
// without ever having received its prompt, so the settle
// path must not take the "I saw it all" shortcut.
Expand Down Expand Up @@ -1033,6 +1054,7 @@

setActiveSessionId(resumed.session_id)
activeSessionIdRef.current = resumed.session_id
const pendingApproval = restorePendingApproval(resumed, resumed.session_id)
const runtimeInfo = applyRuntimeInfo(resumed.info)

patchSessionWorkspace(storedSessionId, runtimeInfo?.cwd)
Expand All @@ -1045,6 +1067,7 @@
messages: messagesForView,
busy: resumedRunning,
awaitingResponse: resumedRunning && !recoveredInFlightTail,
needsInput: pendingApproval || state.needsInput,
adoptedRunningTurn: state.adoptedRunningTurn || resumedRunning,
...(inFlightRecovery.applied
? {
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,16 @@ export interface SessionResumeResponse {
queued?: null | {
user?: string
}
// The oldest gateway approval still waiting for a response. This is returned
// on resume so a reconnect can restore a prompt whose original event was
// emitted while the client transport was detached.
pending_approval?: {
allow_permanent?: boolean
choices?: string[]
command?: string
description?: string
smart_denied?: boolean
}
info?: SessionRuntimeInfo
message_count: number
messages: SessionMessage[]
Expand Down
38 changes: 37 additions & 1 deletion tests/tui_gateway/test_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,43 @@ def test_write_json(capture):
assert json.loads(buf.getvalue()) == {"test": True}


def test_live_session_payload_replays_pending_approval(server, monkeypatch):
"""A reattached client receives the approval that was emitted while detached."""
from tools import approval

session = {
"agent": types.SimpleNamespace(),
"cols": 80,
"created_at": 1.0,
"history": [],
"history_lock": threading.Lock(),
"running": True,
"session_key": "stored-session",
}
first = {
"choices": ["once", "deny"],
"command": "rm -rf /tmp/example",
"description": "recursive delete",
}
second = {"command": "rm -rf /tmp/later", "description": "later"}
saved_queue = approval._gateway_queues.pop("stored-session", None)
approval._gateway_queues["stored-session"] = [
approval._ApprovalEntry(first),
approval._ApprovalEntry(second),
]
monkeypatch.setattr(server, "_approval_request_payload", lambda data: dict(data or {}))

try:
payload = server._live_session_payload("runtime-session", session)
finally:
approval._gateway_queues.pop("stored-session", None)
if saved_queue is not None:
approval._gateway_queues["stored-session"] = saved_queue

assert payload["pending_approval"] == first
assert payload["pending_approval"] is not first


def test_disable_flush_env_var_actually_wires_to_module_constant(monkeypatch):
"""End-to-end: setting `HERMES_TUI_GATEWAY_NO_FLUSH=1` and importing
`tui_gateway.transport` fresh actually flips `_DISABLE_FLUSH` true.
Expand Down Expand Up @@ -739,4 +776,3 @@ def test_unregister_live_transport_stops_delivery(capture):
assert a.frames == []
# No live transports left → fell back to stdio.
assert json.loads(buf.getvalue())["params"]["type"] == "skin.changed"

16 changes: 16 additions & 0 deletions tools/approval.py
Original file line number Diff line number Diff line change
Expand Up @@ -2525,6 +2525,22 @@ def has_blocking_approval(session_key: str) -> bool:
return bool(_gateway_queues.get(session_key))


def get_pending_gateway_approval(session_key: str) -> dict | None:
"""Return a copy of the oldest unresolved gateway approval for a session.

Reconnectable clients use this to restore an approval prompt whose original
notification was sent while their transport was detached. The queue remains
authoritative: this is a read-only snapshot, not a claim on the approval.
"""
if not session_key:
return None
with _lock:
queue = _gateway_queues.get(session_key)
if not queue:
return None
return dict(queue[0].data)


def submit_pending(session_key: str, approval: dict):
"""Store a pending approval request for a session."""
with _lock:
Expand Down
34 changes: 27 additions & 7 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -1823,13 +1823,8 @@ def _send_compute_host_control(
)


def _emit_approval_request(sid: str, data: dict | None) -> None:
"""Emit an ``approval.request`` event to the TUI client with the command
redacted. The approval payload is built from the RAW command string, so a
credential-shaped value Tirith flagged would otherwise be echoed verbatim
to the TUI client (#48456 — third egress transport alongside the chat
platforms and the SSE/API stream fixed in #50767). Reuse the shared gateway
seam so all approval transports redact consistently."""
def _approval_request_payload(data: dict | None) -> dict:
"""Build the client-safe representation of a pending approval."""
payload = dict(data or {})
if "choices" not in payload:
if payload.get("smart_denied"):
Expand All @@ -1842,6 +1837,29 @@ def _emit_approval_request(sid: str, data: dict | None) -> None:
from gateway.run import _redact_approval_command

payload["command"] = _redact_approval_command(payload.get("command"))
return payload


def _pending_approval_request_payload(session_key: str) -> dict | None:
"""Read the oldest unresolved approval in a session, if there is one."""
try:
from tools.approval import get_pending_gateway_approval

approval = get_pending_gateway_approval(session_key)
except Exception:
logger.debug("failed to read pending approval for %s", session_key, exc_info=True)
return None
return _approval_request_payload(approval) if approval else None


def _emit_approval_request(sid: str, data: dict | None) -> None:
"""Emit an ``approval.request`` event to the TUI client with the command
redacted. The approval payload is built from the RAW command string, so a
credential-shaped value Tirith flagged would otherwise be echoed verbatim
to the TUI client (#48456 — third egress transport alongside the chat
platforms and the SSE/API stream fixed in #50767). Reuse the shared gateway
seam so all approval transports redact consistently."""
payload = _approval_request_payload(data)
_emit("approval.request", sid, payload)


Expand Down Expand Up @@ -8139,6 +8157,8 @@ def _live_session_payload(
payload["inflight"] = inflight
if queued:
payload["queued"] = queued
if approval := _pending_approval_request_payload(str(session.get("session_key") or "")):
payload["pending_approval"] = approval
return payload


Expand Down
Loading