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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user