fix(free-tier): put the challenge link in front of ui-tui users

The TUI gateway child reports surface `cli`, so a 428 printed the link
to the child's stderr, which ui-tui keeps as a /logs tail and never
shows (createGatewayEventHandler: gateway.stderr). Over SSH or headless
that was a silent 120 s wait followed by "finish the check in your
browser" with no link. For a required challenge a foreground caller is
waiting on, the stdio TUI gateway now announces it on its JSON-RPC
channel, and ui-tui renders the message + link and opens one tab per
ticket. Background reads (keepalive, status paint) still never pop a
browser, and optional checks stay the desktop's to run hidden.
This commit is contained in:
kshitijk4poor
2026-10-06 16:24:54 +05:30
committed by kshitij
parent 4a370f1c76
commit 1eec4cf274
4 changed files with 80 additions and 3 deletions

View File

@@ -333,11 +333,19 @@ def _present_in_terminal(challenge: BrowserChallenge) -> None:
def present(challenge: BrowserChallenge) -> None:
"""Get the URL in front of something that can load it. Seam for tests (``_presenter``)."""
"""Get the URL in front of something that can load it. Seam for tests (``_presenter``).
The desktop backend hands every challenge to its hidden window. The stdio TUI gateway (whose
stderr the TUI keeps as a log, never shows) hands its client a required one a foreground caller
is waiting on: a background read there must not pop a browser any more than in a terminal."""
if client_surface() == "desktop" and _announce(challenge):
return
if challenge.required and not _background.get():
_present_in_terminal(challenge)
if not challenge.required or _background.get():
return
server = sys.modules.get("tui_gateway.server")
if server is not None and server._stdio_is_rpc_channel and _announce(challenge):
return
_present_in_terminal(challenge)
_presenter: Callable[[BrowserChallenge], None] = present

View File

@@ -402,6 +402,23 @@ class TestPresenting:
anon_challenge.present(challenge)
assert announced == [challenge] and opened == []
def test_the_stdio_tui_gateway_hands_the_link_to_its_client(self, monkeypatch, capsys):
"""The TUI keeps its gateway's stderr as a log the user never sees: the challenge goes to the
client over the JSON-RPC channel instead of being printed there."""
import sys
import types
sent = []
server = types.SimpleNamespace(_stdio_is_rpc_channel=True,
_broadcast_global_event=lambda event, payload: sent.append((event, payload)))
monkeypatch.setitem(sys.modules, "tui_gateway.server", server)
challenge = anon_challenge.BrowserChallenge(f"{PORTAL}/challenge?code=t", True, 600, 2, "m")
with anon_challenge.background_caller():
anon_challenge.present(challenge)
assert sent == [] # a keepalive tick never pops a browser
anon_challenge.present(challenge)
assert sent == [(anon_challenge.CHALLENGE_EVENT, challenge.as_payload())]
assert capsys.readouterr().err == ""
def test_a_terminal_opens_one_tab_per_ticket_however_many_attempts_resume_it(self, monkeypatch, capsys):
import webbrowser
from hermes_cli import auth_device_flow

View File

@@ -2247,6 +2247,34 @@ describe('createGatewayEventHandler', () => {
})
})
describe('free_tier.challenge', () => {
beforeEach(() => {
openExternalUrlMock.mockClear()
})
it('shows the link and opens one tab per ticket; an optional check opens nothing', () => {
const ctx = buildCtx([])
const onEvent = createGatewayEventHandler(ctx)
const url = 'https://portal.example/challenge?code=t'
const event = (required: boolean) =>
({
payload: { attempt: 0, expires_in: 600, message: 'A quick check first.', required, type: 'browser', url },
type: 'free_tier.challenge'
}) as any
onEvent(event(false))
expect(openExternalUrlMock).not.toHaveBeenCalled()
onEvent(event(true))
onEvent(event(true))
const printed = (ctx.system.sys as ReturnType<typeof vi.fn>).mock.calls.map(c => c[0]).join('\n')
expect(printed).toContain(url)
expect(openExternalUrlMock).toHaveBeenCalledTimes(1)
})
})
describe('session.usage', () => {
it('merges a live usage tick into uiState (payload.usage shape, see tui_gateway _start_usage_ticker)', () => {
patchUiState({ sid: 'sess-1' })

View File

@@ -464,6 +464,8 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
// an abandoned-prompt record, so the tool.complete and message.complete
// paths can't both persist the same prompt twice.
const persistedAbandonedClarify = new Set<string>()
// One browser tab per free-tier challenge ticket, however many attempts resume it.
const openedChallengeUrls = new Set<string>()
// When a clarify prompt is dismissed without an answer (the backend request
// timed out and returned no answer), the live ClarifyPrompt overlay is
@@ -827,6 +829,28 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
return
case 'free_tier.challenge': {
// The account service wants a browser check before the free-tier token
// (hermes_cli/anon_challenge.py). The gateway polls for the result on
// its own; this only puts the link where the user is. An optional
// check is the desktop's to run hidden, never the user's.
const challenge = ev.payload
if (!challenge?.required || !challenge.url) {
return
}
sys(challenge.message)
sys(challenge.url)
if (!openedChallengeUrls.has(challenge.url)) {
openedChallengeUrls.add(challenge.url)
void openExternalUrl(challenge.url)
}
return
}
case 'session.info': {
let info = ev.payload as SessionInfo | undefined