From d0461ba6c38162fa40e6ec7088d59dd4cdaa07f3 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:43:31 +0530 Subject: [PATCH] refactor(desktop): free-tier challenge reuses the window-open policy and the contract's outcome type - challenge-window.ts uses createWindowOpenHandler (deny + sanitised origin log, the link-title window's own hardening) instead of an inline deny. - The renderer's outcome type is the generated contract's FreeTierChallengeResultParams['outcome']; global.d.ts uses the main process's ChallengeOutcome instead of a third hand-copied union, so a drift between the window and the contract fails the renderer typecheck. - runFreeTierChallenge takes the gateway requester itself and builds the free_tier.challenge_result call, so lifecycle.ts and free-tier.ts stop each hand-rolling a reporter closure. No behaviour change. --- apps/desktop/electron/challenge-window.ts | 8 +++++++- .../gateway-event/lifecycle.ts | 4 +--- apps/desktop/src/global.d.ts | 3 ++- .../src/store/free-tier-challenge.test.ts | 2 +- apps/desktop/src/store/free-tier-challenge.ts | 16 +++++++--------- apps/desktop/src/store/free-tier.ts | 2 +- 6 files changed, 19 insertions(+), 16 deletions(-) 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 {