diff --git a/apps/desktop/electron/challenge-window.ts b/apps/desktop/electron/challenge-window.ts index f7cb0ab3cf..d3f9072092 100644 --- a/apps/desktop/electron/challenge-window.ts +++ b/apps/desktop/electron/challenge-window.ts @@ -1,5 +1,7 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, Session } from 'electron' +import { createWindowOpenHandler } from './window-open-policy' + /** * The free tier's browser challenge, desktop half. * @@ -33,6 +35,8 @@ import type { BrowserWindow, BrowserWindowConstructorOptions, Session } from 'el export type ChallengePhase = 'working' | 'interactive' | 'done' | 'failed' +// The contract's ``free_tier.challenge_result`` outcomes minus the renderer-only 'unsupported'; +// the renderer reports these through that type, so a drift fails its typecheck. export type ChallengeOutcome = 'done' | 'failed' | 'closed' | 'timeout' | 'refused' | 'error' export interface ChallengeRequest { @@ -255,7 +259,9 @@ export function createChallengeWindows({ const contents = win.webContents contents.setAudioMuted(true) - contents.setWindowOpenHandler(() => ({ action: 'deny' })) + contents.setWindowOpenHandler( + createWindowOpenHandler(origin => rememberLog(`[free-tier] challenge popup denied: ${origin}`)) + ) // The page may move within the portal (a fragment, a reload); it may not // take this window anywhere else — by script (`will-navigate`) or by a diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts index 5074f371a5..5ef4221cb5 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/lifecycle.ts @@ -63,9 +63,7 @@ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean { const connectionId = event.connectionId ?? activeGatewayConnectionId() const profile = event.profile ?? deps.activeGatewayProfile - void runFreeTierChallenge(payload, result => - requestGatewayForAgent(connectionId, profile, 'free_tier.challenge_result', result) - ) + void runFreeTierChallenge(payload, (method, params) => requestGatewayForAgent(connectionId, profile, method, params)) return true } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 811c15fa1c..007eb281d3 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -2,6 +2,7 @@ import type { GatewayWsUrlResult } from '@hermes/shared' import type { HermesSkin } from '@hermes/shared/skin' import type { TranslucencyState } from '@hermes/shared/translucency' +import type { ChallengeOutcome } from '../electron/challenge-window' import type { ScreenshotApi } from '../electron/command-screenshot-types' import type { HudModifierApi } from '../electron/hud-modifier-types' import type { MachineProfile } from '../electron/machine-profile' @@ -422,7 +423,7 @@ declare global { required: boolean expiresIn?: number attempt?: number - }) => Promise<'done' | 'failed' | 'closed' | 'timeout' | 'refused' | 'error'> + }) => Promise } /** One-shot loopback callback listener for MCP OAuth against remote * backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine, diff --git a/apps/desktop/src/store/free-tier-challenge.test.ts b/apps/desktop/src/store/free-tier-challenge.test.ts index 296cc5caf2..fa4e1f71e4 100644 --- a/apps/desktop/src/store/free-tier-challenge.test.ts +++ b/apps/desktop/src/store/free-tier-challenge.test.ts @@ -26,7 +26,7 @@ describe('runFreeTierChallenge', () => { installBridge(run) await expect(runFreeTierChallenge({ ...challenge, attempt: 3 }, report)).resolves.toBe('error') expect(run).toHaveBeenCalledWith(expect.objectContaining({ attempt: 3 })) - expect(report).toHaveBeenCalledWith({ url: challenge.url, attempt: 3, outcome: 'error' }) + expect(report).toHaveBeenCalledWith('free_tier.challenge_result', { url: challenge.url, attempt: 3, outcome: 'error' }) }) it('hands a browser challenge to the main process', async () => { const run = vi.fn().mockResolvedValue('done') diff --git a/apps/desktop/src/store/free-tier-challenge.ts b/apps/desktop/src/store/free-tier-challenge.ts index 26fc6619cf..7781c32fdc 100644 --- a/apps/desktop/src/store/free-tier-challenge.ts +++ b/apps/desktop/src/store/free-tier-challenge.ts @@ -1,4 +1,4 @@ -import type { FreeTierChallengePayload } from '@hermes/shared' +import type { FreeTierChallengePayload, FreeTierChallengeResultParams } from '@hermes/shared' /** * The free tier's browser challenge, renderer half: a relay. The backend @@ -10,14 +10,12 @@ import type { FreeTierChallengePayload } from '@hermes/shared' * The event and a status read can both name the same attempt. */ -export type FreeTierChallengeOutcome = 'done' | 'failed' | 'closed' | 'timeout' | 'refused' | 'error' | 'unsupported' +export type FreeTierChallengeOutcome = FreeTierChallengeResultParams['outcome'] + +/** Sends ``free_tier.challenge_result`` to the backend that announced the challenge. */ +export type ChallengeRequester = (method: 'free_tier.challenge_result', params: Record) => Promise const inFlight = new Map>() -type ChallengeReporter = (result: { - url: string - attempt: number - outcome: FreeTierChallengeOutcome -}) => Promise function isBrowserChallenge(value: unknown): value is FreeTierChallengePayload { if (typeof value !== 'object' || value === null) { @@ -31,7 +29,7 @@ function isBrowserChallenge(value: unknown): value is FreeTierChallengePayload { export function runFreeTierChallenge( challenge: unknown, - report?: ChallengeReporter + requestGateway?: ChallengeRequester ): Promise | null { if (!isBrowserChallenge(challenge)) { return null @@ -52,7 +50,7 @@ export function runFreeTierChallenge( // never grants a credential, so a failed report is not worth surfacing. const reported = async (outcome: FreeTierChallengeOutcome) => { try { - await report?.({ url: challenge.url, attempt, outcome }) + await requestGateway?.('free_tier.challenge_result', { url: challenge.url, attempt, outcome }) } catch { // see above } diff --git a/apps/desktop/src/store/free-tier.ts b/apps/desktop/src/store/free-tier.ts index b528141ec3..42924d4119 100644 --- a/apps/desktop/src/store/free-tier.ts +++ b/apps/desktop/src/store/free-tier.ts @@ -47,7 +47,7 @@ export async function refreshFreeTierStatus(requestGateway: FreeTierRequester): $freeTierStatus.set(status) // A client that connected after the `free_tier.challenge` event still has // a window to open; the run is de-duplicated per URL. - void runFreeTierChallenge(status.challenge, result => requestGateway('free_tier.challenge_result', result)) + void runFreeTierChallenge(status.challenge, requestGateway) return status } catch {