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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
3
apps/desktop/src/global.d.ts
vendored
3
apps/desktop/src/global.d.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user