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.
This commit is contained in:
kshitijk4poor
2026-10-06 16:43:31 +05:30
committed by kshitij
parent d1f587331e
commit d0461ba6c3
6 changed files with 19 additions and 16 deletions

View File

@@ -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

View File

@@ -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
}

View File

@@ -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<ChallengeOutcome>
}
/** One-shot loopback callback listener for MCP OAuth against remote
* backends (electron/mcp-oauth-callback-ipc.ts): bind on THIS machine,

View File

@@ -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')

View File

@@ -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<string, unknown>) => Promise<unknown>
const inFlight = new Map<string, Promise<FreeTierChallengeOutcome>>()
type ChallengeReporter = (result: {
url: string
attempt: number
outcome: FreeTierChallengeOutcome
}) => Promise<unknown>
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<FreeTierChallengeOutcome> | 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
}

View File

@@ -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 {