diff --git a/hermes_cli/anon_challenge.py b/hermes_cli/anon_challenge.py index 410e37c50e..88ac154f9c 100644 --- a/hermes_cli/anon_challenge.py +++ b/hermes_cli/anon_challenge.py @@ -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 diff --git a/tests/hermes_cli/test_anon_challenge.py b/tests/hermes_cli/test_anon_challenge.py index c1cff32a81..0543823743 100644 --- a/tests/hermes_cli/test_anon_challenge.py +++ b/tests/hermes_cli/test_anon_challenge.py @@ -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 diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 3761d45999..ccc0c777ae 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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).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' }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 41c6008daa..f24ecce092 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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() + // One browser tab per free-tier challenge ticket, however many attempts resume it. + const openedChallengeUrls = new Set() // 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