fix(browser): reject an unrecognized identity mode at the IPC door

normalizeBrowserUserAgentMode turned any unrecognized value into 'clean', so the
IPC door reported success for a mode it had quietly replaced, while the RPC door
validates against z.enum(['clean', 'native']) and rejects. One concept answered
an unknown value two different ways, and a future mode name was silently
downgraded rather than refused.

The handler now rejects, which is what the RPC door does and what the renderer
already handles — its catch puts the message in the error slot. Returning a
result instead would have meant inventing a fourth error code for a case no
legitimate caller can reach.

normalizeBrowserUserAgentMode had no other consumer, so it goes with the change:
leaving a coercion helper called "normalize" in shared/ invites the behaviour
straight back in.
This commit is contained in:
Brennan Benson
2026-09-14 21:38:18 -07:00
parent 0ec96625b6
commit be0def2268
3 changed files with 57 additions and 14 deletions
@@ -1,13 +1,25 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { handleMock, removeHandlerMock, createProfileMock, routeIdentityMock, detectBrowsersMock } =
vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
createProfileMock: vi.fn(),
routeIdentityMock: vi.fn(),
detectBrowsersMock: vi.fn(() => [])
}))
const {
handleMock,
removeHandlerMock,
createProfileMock,
routeIdentityMock,
detectBrowsersMock,
setBrowserIdentityModeMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
createProfileMock: vi.fn(),
routeIdentityMock: vi.fn(),
detectBrowsersMock: vi.fn(() => []),
setBrowserIdentityModeMock: vi.fn(async () => ({ ok: true }))
}))
vi.mock('../browser/browser-identity-mode-store', () => ({
setBrowserIdentityMode: setBrowserIdentityModeMock,
getBrowserIdentityModeStatus: vi.fn(() => ({ identity: {}, migrationNotice: null }))
}))
vi.mock('electron', () => ({
BrowserWindow: { fromWebContents: vi.fn() },
@@ -51,6 +63,8 @@ describe('browser session profile IPC', () => {
routeIdentityMock.mockReset()
detectBrowsersMock.mockReset()
detectBrowsersMock.mockReturnValue([])
setBrowserIdentityModeMock.mockReset()
setBrowserIdentityModeMock.mockResolvedValue({ ok: true })
setTrustedBrowserRendererWebContentsId(null)
})
@@ -63,6 +77,33 @@ describe('browser session profile IPC', () => {
} as Electron.WebContents
}
function identitySetHandler(): (
event: { sender: Electron.WebContents },
mode: unknown
) => Promise<unknown> {
registerBrowserHandlers()
return handleMock.mock.calls.find(([channel]) => channel === 'browser:identity:set')?.[1]
}
// Why reject rather than coerce: the RPC door validates mode against z.enum(['clean','native'])
// and rejects. Coercing an unrecognized value to 'clean' here made one concept answer an unknown
// value two different ways, and reported success for a mode that was quietly replaced.
it('refuses an unrecognized identity mode instead of silently selecting Cleaned', async () => {
setTrustedBrowserRendererWebContentsId(91)
const handler = identitySetHandler()
await expect(handler({ sender: trustedSender() }, 'rotating')).rejects.toThrow(/rotating/)
expect(setBrowserIdentityModeMock).not.toHaveBeenCalled()
})
it('commits a recognized identity mode unchanged', async () => {
setTrustedBrowserRendererWebContentsId(91)
const handler = identitySetHandler()
await expect(handler({ sender: trustedSender() }, 'native')).resolves.toEqual({ ok: true })
expect(setBrowserIdentityModeMock).toHaveBeenCalledWith('native')
})
function clientHostDetectHandler(): (
event: { sender: Electron.WebContents },
args: { environmentId: string }
+8 -2
View File
@@ -20,7 +20,6 @@ import {
getBrowserIdentityModeStatus,
setBrowserIdentityMode
} from '../browser/browser-identity-mode-store'
import { normalizeBrowserUserAgentMode } from '../../shared/browser-user-agent-mode'
export function registerBrowserSessionProfileHandlers(): void {
ipcMain.removeHandler('browser:session:listProfiles')
@@ -62,7 +61,14 @@ export function registerBrowserSessionProfileHandlers(): void {
if (!isTrustedBrowserRenderer(event.sender)) {
return null
}
return setBrowserIdentityMode(normalizeBrowserUserAgentMode(mode))
// Why reject rather than coerce: the RPC door validates against z.enum(['clean', 'native'])
// and rejects. Coercing an unrecognized value to 'clean' made one concept answer an unknown
// value two different ways, and reported success for a mode that was quietly replaced —
// silently downgrading a future mode name the caller believed was honoured.
if (mode !== 'clean' && mode !== 'native') {
throw new Error(`Unsupported browser identity mode: ${String(mode)}`)
}
return setBrowserIdentityMode(mode)
})
ipcMain.handle(
-4
View File
@@ -36,7 +36,3 @@ export type BrowserIdentityModeSetResult =
}
identity: BrowserIdentityModeSnapshot
}
export function normalizeBrowserUserAgentMode(mode: unknown): BrowserUserAgentMode {
return mode === 'native' ? 'native' : 'clean'
}