From 7db9c54b5418270ee287dc57501a8c8436c82fc3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 14 Sep 2026 17:53:36 -0700 Subject: [PATCH] WIP: rescue in-flight reduced-design work from a dead worker Worker ctx_cb5b1262d7fe stopped ~2h ago mid-implementation (last heartbeat 2026-09-14T22:48:06Z) leaving this uncommitted. Committed unverified to make it recoverable; not reviewed, not necessarily green. --- src/cli/browser-handler-groups.ts | 5 + src/cli/browser.test.ts | 70 ++++ src/cli/handlers/browser-identity.ts | 60 +++ src/cli/specs/browser-basic.ts | 12 + .../browser-identity-mode-record.test.ts | 91 +++++ .../browser/browser-identity-mode-record.ts | 347 +++++++++++++----- .../browser-identity-mode-store.test.ts | 130 +++++++ ...er-session-persisted-profile-validation.ts | 50 +-- src/main/browser/browser-session-registry.ts | 23 +- ...on-user-agent-migration-inspection.test.ts | 40 ++ src/main/ipc/browser-session-profile-ipc.ts | 20 +- .../applying-settings/settings-update.ts | 6 - .../normalize-loaded-global-settings.test.ts | 8 + .../normalize-loaded-global-settings.ts | 2 - src/main/runtime/rpc/methods/browser-core.ts | 15 + .../rpc/methods/browser-identity-rpc.test.ts | 41 +++ .../runtime/rpc/methods/browser-schemas.ts | 1 + src/main/runtime/rpc/methods/browser.test.ts | 6 +- src/main/server/serve-stdout-boundary.test.ts | 47 ++- src/main/server/serve-stdout-boundary.ts | 25 ++ ...rowser-process-user-agent-ordering.test.ts | 10 +- src/main/startup/main-process-preflight.ts | 6 +- .../startup/main-process-ready-foundation.ts | 6 - .../main-process-ready-identity-write.test.ts | 40 +- .../startup/main-process-runtime-launch.ts | 3 + src/preload/api/browser-api.ts | 9 +- ...er-bridge-page-interaction-and-sessions.ts | 7 +- .../src/app-shell/use-app-shell-services.ts | 2 + src/renderer/src/app-startup-routing.test.ts | 9 + .../browser-user-agent-migration-notice.ts | 70 ++-- .../browser-page-webview-guest-session.ts | 2 - .../src/components/settings/BrowserPane.tsx | 2 +- .../settings/BrowserUserAgentSetting.test.tsx | 36 ++ .../settings/BrowserUserAgentSetting.tsx | 152 ++++++-- src/shared/browser-user-agent-mode.ts | 34 ++ src/shared/default-global-settings.ts | 1 - src/shared/global-settings-types.ts | 2 - src/shared/protocol-version.ts | 2 + src/shared/rpc-contract/browser-params.ts | 26 +- 39 files changed, 1139 insertions(+), 279 deletions(-) create mode 100644 src/cli/handlers/browser-identity.ts create mode 100644 src/main/browser/browser-identity-mode-record.test.ts create mode 100644 src/main/browser/browser-identity-mode-store.test.ts create mode 100644 src/main/browser/browser-session-user-agent-migration-inspection.test.ts create mode 100644 src/main/runtime/rpc/methods/browser-identity-rpc.test.ts create mode 100644 src/renderer/src/components/settings/BrowserUserAgentSetting.test.tsx diff --git a/src/cli/browser-handler-groups.ts b/src/cli/browser-handler-groups.ts index 3567e8b0596..87cf8850380 100644 --- a/src/cli/browser-handler-groups.ts +++ b/src/cli/browser-handler-groups.ts @@ -3,6 +3,11 @@ import type { HandlerGroup } from './handler-group-manifest' // Why split out: the browser command surface is a third of the CLI's groups and // changes as a unit, so it keeps handler-group-manifest.ts readable at a glance. export const BROWSER_HANDLER_GROUPS: readonly HandlerGroup[] = [ + { + name: 'browser-identity', + keys: ['browser identity get', 'browser identity set'], + load: async () => (await import('./handlers/browser-identity.js')).BROWSER_IDENTITY_HANDLERS + }, { name: 'browser-nav', keys: [ diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index 04a6363f2cc..2bdb8ab0afb 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -314,6 +314,76 @@ describe('orca cli browser page targeting', () => { }) }) +describe('orca cli browser identity', () => { + beforeEach(() => { + callMock.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('gets the host identity only after capability negotiation', async () => { + queueFixtures( + callMock, + okFixture('status', { capabilities: ['browser.identity.v1'] }), + okFixture('identity', { + identity: { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'native', + explicitSelection: true, + migrationNoticePending: false, + restartRequired: true + }, + migrationNotice: null + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['browser', 'identity', 'get', '--json'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenNthCalledWith(1, 'status.get') + expect(callMock).toHaveBeenNthCalledWith(2, 'browser.identity.get') + }) + + it('sets the host identity through the runtime writer', async () => { + queueFixtures( + callMock, + okFixture('status', { capabilities: ['browser.identity.v1'] }), + okFixture('identity', { + ok: true, + identity: { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'native', + explicitSelection: true, + migrationNoticePending: false, + restartRequired: true + } + }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main( + ['browser', 'identity', 'set', '--mode', 'native', '--json'], + '/tmp/not-an-orca-worktree' + ) + + expect(callMock).toHaveBeenNthCalledWith(2, 'browser.identity.set', { mode: 'native' }) + }) + + it('refuses an older runtime instead of guessing at identity support', async () => { + queueFixtures(callMock, okFixture('status', { capabilities: [] })) + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + + await main(['browser', 'identity', 'get'], '/tmp/not-an-orca-worktree') + + expect(callMock).toHaveBeenCalledTimes(1) + expect(error).toHaveBeenCalledWith(expect.stringContaining('Update or restart Orca')) + }) +}) + describe('orca cli browser profile management', () => { beforeEach(() => { callMock.mockReset() diff --git a/src/cli/handlers/browser-identity.ts b/src/cli/handlers/browser-identity.ts new file mode 100644 index 00000000000..cbbd92f64d0 --- /dev/null +++ b/src/cli/handlers/browser-identity.ts @@ -0,0 +1,60 @@ +import type { + BrowserIdentityModeSetResult, + BrowserIdentityModeStatus, + BrowserUserAgentMode +} from '../../shared/browser-user-agent-mode' +import { BROWSER_IDENTITY_RUNTIME_CAPABILITY } from '../../shared/protocol-version' +import type { RuntimeStatus } from '../../shared/runtime-types' +import type { CommandHandler, HandlerContext } from '../dispatch' +import { getRequiredStringFlag } from '../flags' +import { printResult } from '../format' +import { RuntimeClientError } from '../runtime-client' + +async function assertBrowserIdentitySupported({ client }: HandlerContext): Promise { + const status = await client.call('status.get') + if (!status.result.capabilities?.includes(BROWSER_IDENTITY_RUNTIME_CAPABILITY)) { + throw new RuntimeClientError( + 'incompatible_runtime', + 'The running Orca runtime does not support browser identity management. Update or restart Orca and try again.' + ) + } +} + +function parseMode(flags: Map): BrowserUserAgentMode { + const mode = getRequiredStringFlag(flags, 'mode') + if (mode !== 'clean' && mode !== 'native') { + throw new RuntimeClientError('invalid_argument', '--mode must be "clean" or "native"') + } + return mode +} + +function formatStatus(status: BrowserIdentityModeStatus): string { + const { identity } = status + if (identity.configuredMode === null) { + return `Browser identity: ${identity.state}; Cleaned is applied for this launch. Explicit reset required.` + } + return `Browser identity: ${identity.configuredMode} (applied: ${identity.appliedMode}${identity.restartRequired ? ', restart required' : ''})` +} + +export const BROWSER_IDENTITY_HANDLERS: Record = { + 'browser identity get': async (context) => { + await assertBrowserIdentitySupported(context) + const result = await context.client.call('browser.identity.get') + printResult(result, context.json, formatStatus) + }, + 'browser identity set': async (context) => { + const mode = parseMode(context.flags) + await assertBrowserIdentitySupported(context) + const result = await context.client.call('browser.identity.set', { + mode + }) + if (!result.result.ok) { + throw new RuntimeClientError(result.result.error.code, result.result.error.message) + } + printResult(result, context.json, ({ identity }) => + identity.restartRequired + ? `Browser identity set to ${identity.configuredMode}; restart Orca to apply it.` + : `Browser identity set to ${identity.configuredMode}.` + ) + } +} diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 7691946824e..cde5697bc98 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -2,6 +2,18 @@ import type { CommandSpec } from '../args' import { GLOBAL_FLAGS } from '../args' export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['browser', 'identity', 'get'], + summary: 'Show the browser identity configured on this Orca host', + usage: 'orca browser identity get [--json]', + allowedFlags: [...GLOBAL_FLAGS] + }, + { + path: ['browser', 'identity', 'set'], + summary: 'Choose the browser identity for every page on this Orca host', + usage: 'orca browser identity set --mode [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'mode'] + }, { path: ['open-url'], summary: 'Open a URL on the paired client that hosts this terminal', diff --git a/src/main/browser/browser-identity-mode-record.test.ts b/src/main/browser/browser-identity-mode-record.test.ts new file mode 100644 index 00000000000..f83932e9bd4 --- /dev/null +++ b/src/main/browser/browser-identity-mode-record.test.ts @@ -0,0 +1,91 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + BROWSER_IDENTITY_MODE_FILE, + BROWSER_IDENTITY_MODE_VERSION, + readBrowserIdentityModeRecord +} from './browser-identity-mode-record' + +function makeUserData(): string { + return mkdtempSync(join(tmpdir(), 'orca-browser-identity-')) +} + +function writeRecord(userDataPath: string, value: unknown): void { + writeFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), JSON.stringify(value), 'utf8') +} + +describe('readBrowserIdentityModeRecord', () => { + it('distinguishes missing data as implicit clean', () => { + expect(readBrowserIdentityModeRecord(makeUserData())).toEqual({ + state: 'missing', + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: false, + migrationNoticePending: false + }) + }) + + it('returns a valid configured identity', () => { + const userDataPath = makeUserData() + writeRecord(userDataPath, { + version: BROWSER_IDENTITY_MODE_VERSION, + mode: 'native', + explicitSelection: true, + migrationNoticePending: true + }) + + expect(readBrowserIdentityModeRecord(userDataPath)).toEqual({ + state: 'valid', + appliedMode: 'native', + configuredMode: 'native', + explicitSelection: true, + migrationNoticePending: true + }) + }) + + it('falls back to clean without inventing a configured mode for corrupt data', () => { + const userDataPath = makeUserData() + writeFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), '{not json', 'utf8') + + expect(readBrowserIdentityModeRecord(userDataPath)).toEqual({ + state: 'corrupt', + appliedMode: 'clean', + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null + }) + }) + + it('distinguishes a future record from corrupt data', () => { + const userDataPath = makeUserData() + writeRecord(userDataPath, { + version: BROWSER_IDENTITY_MODE_VERSION + 1, + mode: 'native', + explicitSelection: true, + migrationNoticePending: false + }) + + expect(readBrowserIdentityModeRecord(userDataPath)).toEqual({ + state: 'future', + appliedMode: 'clean', + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null + }) + }) + + it('distinguishes an unreadable record from missing data', () => { + const userDataPath = makeUserData() + mkdirSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE)) + + expect(readBrowserIdentityModeRecord(userDataPath)).toEqual({ + state: 'unreadable', + appliedMode: 'clean', + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null + }) + }) +}) diff --git a/src/main/browser/browser-identity-mode-record.ts b/src/main/browser/browser-identity-mode-record.ts index 4d86e40e600..b7164cbf9ce 100644 --- a/src/main/browser/browser-identity-mode-record.ts +++ b/src/main/browser/browser-identity-mode-record.ts @@ -1,7 +1,12 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { writeFileAtomically } from '../codex-accounts/fs-utils' -import type { BrowserUserAgentMode } from '../../shared/browser-user-agent-mode' +import { durableWriteTempPath, writeFileDurableSync } from '../durable-file-write' +import type { + BrowserIdentityModeStatus, + BrowserIdentityModeSetResult, + BrowserIdentityModeSnapshot, + BrowserUserAgentMode +} from '../../shared/browser-user-agent-mode' /** * The browser's identity is one process-wide decision, not a per-profile one. @@ -22,129 +27,289 @@ import type { BrowserUserAgentMode } from '../../shared/browser-user-agent-mode' export const BROWSER_IDENTITY_MODE_FILE = 'browser-identity-mode.json' export const BROWSER_IDENTITY_MODE_VERSION = 1 -let browserIdentityPersistenceFailure: string | null = null - -export function getBrowserIdentityPersistenceFailure(): string | null { - return browserIdentityPersistenceFailure -} - export type BrowserIdentityModeRecord = { version: typeof BROWSER_IDENTITY_MODE_VERSION mode: BrowserUserAgentMode - /** Profiles that carried the retired per-profile `native` mode, so the browser can say so once. */ - migratedNativeProfileIds?: string[] - /** Cleared after the renderer confirms it displayed the migration notice. */ - migrationNoticePending?: boolean + explicitSelection: boolean + migrationNoticePending: boolean } +type HealthyBrowserIdentityModeReadResult = { + state: 'missing' | 'valid' + appliedMode: BrowserUserAgentMode + configuredMode: BrowserUserAgentMode + explicitSelection: boolean + migrationNoticePending: boolean +} + +type UnhealthyBrowserIdentityModeReadResult = { + state: 'corrupt' | 'future' | 'unreadable' + appliedMode: 'clean' + configuredMode: null + explicitSelection: null + migrationNoticePending: null +} + +export type BrowserIdentityModeReadResult = + | HealthyBrowserIdentityModeReadResult + | UnhealthyBrowserIdentityModeReadResult + +type BrowserIdentityModeStore = { + userDataPath: string + snapshot: BrowserIdentityModeSnapshot +} + +let modeStore: BrowserIdentityModeStore | null = null +let writeQueue: Promise = Promise.resolve() +const snapshotListeners = new Set<(snapshot: BrowserIdentityModeSnapshot) => void>() +let migrationNoticeDegraded = false +let launchMigrationNoticePending = false + export function browserIdentityModeRecordPath(userDataPath: string): string { return join(userDataPath, BROWSER_IDENTITY_MODE_FILE) } -function parseRecord(raw: string): BrowserIdentityModeRecord | null { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: JSON.parse is untyped; every member is checked below and an unrecognised shape returns null. - const parsed = JSON.parse(raw) as Partial - if (parsed.version !== BROWSER_IDENTITY_MODE_VERSION) { - return null +function parseRecord(raw: string): BrowserIdentityModeReadResult { + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + return unhealthyResult('corrupt') } - if (parsed.mode !== 'clean' && parsed.mode !== 'native') { - return null + if (!parsed || typeof parsed !== 'object') { + return unhealthyResult('corrupt') + } + const version = Reflect.get(parsed, 'version') + if (typeof version === 'number' && version > BROWSER_IDENTITY_MODE_VERSION) { + return unhealthyResult('future') + } + const mode = Reflect.get(parsed, 'mode') + const explicitSelection = Reflect.get(parsed, 'explicitSelection') + const migrationNoticePending = Reflect.get(parsed, 'migrationNoticePending') + if ( + version !== BROWSER_IDENTITY_MODE_VERSION || + (mode !== 'clean' && mode !== 'native') || + typeof explicitSelection !== 'boolean' || + typeof migrationNoticePending !== 'boolean' + ) { + return unhealthyResult('corrupt') } return { - version: BROWSER_IDENTITY_MODE_VERSION, - mode: parsed.mode, - migratedNativeProfileIds: Array.isArray(parsed.migratedNativeProfileIds) - ? parsed.migratedNativeProfileIds.filter((id): id is string => typeof id === 'string') - : undefined, - migrationNoticePending: parsed.migrationNoticePending === true ? true : undefined + state: 'valid', + appliedMode: mode, + configuredMode: mode, + explicitSelection, + migrationNoticePending } } -/** Absent, unreadable, or unrecognised all mean `clean` — the default that keeps imported cookies alive. */ -export function readBrowserIdentityModeRecord(userDataPath: string): BrowserIdentityModeRecord { +function unhealthyResult( + state: UnhealthyBrowserIdentityModeReadResult['state'] +): UnhealthyBrowserIdentityModeReadResult { + return { + state, + appliedMode: 'clean', + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null + } +} + +/** Reads the process identity synchronously before Electron readiness. */ +export function readBrowserIdentityModeRecord( + userDataPath: string +): BrowserIdentityModeReadResult { try { - const parsed = parseRecord(readFileSync(browserIdentityModeRecordPath(userDataPath), 'utf-8')) - return parsed ?? { version: BROWSER_IDENTITY_MODE_VERSION, mode: 'clean' } - } catch { - return { version: BROWSER_IDENTITY_MODE_VERSION, mode: 'clean' } + return parseRecord(readFileSync(browserIdentityModeRecordPath(userDataPath), 'utf-8')) + } catch (error) { + if (error instanceof Error && Reflect.get(error, 'code') === 'ENOENT') { + return { + state: 'missing', + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: false, + migrationNoticePending: false + } + } + return unhealthyResult('unreadable') } } -export function writeBrowserIdentityModeRecord( +function writeBrowserIdentityModeRecord( userDataPath: string, record: BrowserIdentityModeRecord ): void { - writeFileAtomically( - browserIdentityModeRecordPath(userDataPath), + const filePath = browserIdentityModeRecordPath(userDataPath) + writeFileDurableSync( + durableWriteTempPath(filePath), + filePath, `${JSON.stringify(record, null, 2)}\n` ) } -function persistBrowserIdentityOperation( - userDataPath: string, - record: BrowserIdentityModeRecord, - operation: string -): boolean { - try { - writeBrowserIdentityModeRecord(userDataPath, record) - browserIdentityPersistenceFailure = null - return true - } catch (error) { - browserIdentityPersistenceFailure = error instanceof Error ? error.message : String(error) - console.error( - `[browser-identity] Could not persist ${operation}:`, - browserIdentityPersistenceFailure - ) - return false +function snapshotForRead(result: BrowserIdentityModeReadResult): BrowserIdentityModeSnapshot { + return { ...result, restartRequired: false } +} + +export function initializeBrowserIdentityModeStore( + userDataPath: string +): BrowserIdentityModeSnapshot { + if (modeStore) { + throw new Error('Browser identity mode store was already initialized') + } + const snapshot = snapshotForRead(readBrowserIdentityModeRecord(userDataPath)) + modeStore = { userDataPath, snapshot } + return snapshot +} + +function requireModeStore(): BrowserIdentityModeStore { + if (!modeStore) { + throw new Error('Browser identity mode store is not initialized') + } + return modeStore +} + +export function getBrowserIdentityModeSnapshot(): BrowserIdentityModeSnapshot { + return requireModeStore().snapshot +} + +export function getBrowserIdentityMigrationNotice(): { degraded: boolean } | null { + const snapshot = requireModeStore().snapshot + return launchMigrationNoticePending || snapshot.migrationNoticePending === true + ? { degraded: migrationNoticeDegraded } + : null +} + +export function getBrowserIdentityModeStatus(): BrowserIdentityModeStatus { + return { + identity: getBrowserIdentityModeSnapshot(), + migrationNotice: getBrowserIdentityMigrationNotice() } } -export function updateBrowserIdentityMode(userDataPath: string, mode: BrowserUserAgentMode): void { - const current = readBrowserIdentityModeRecord(userDataPath) - if (current.mode === mode) { - return +function notifySnapshotListeners(snapshot: BrowserIdentityModeSnapshot): void { + for (const listener of snapshotListeners) { + try { + listener(snapshot) + } catch (error) { + console.error('[browser-identity] Snapshot listener failed:', error) + } } - persistBrowserIdentityOperation(userDataPath, { ...current, mode }, 'process identity mode') } -export function recordRetiredNativeBrowserProfiles( - userDataPath: string, - profileIds: readonly string[] -): boolean { - if (profileIds.length === 0) { - return true - } - const current = readBrowserIdentityModeRecord(userDataPath) - const migratedNativeProfileIds = [ - ...new Set([...(current.migratedNativeProfileIds ?? []), ...profileIds]) - ] - return persistBrowserIdentityOperation( - userDataPath, - { - ...current, - migratedNativeProfileIds, - migrationNoticePending: true - }, - 'retired profile notice' +export function onBrowserIdentityModeSnapshotChanged( + listener: (snapshot: BrowserIdentityModeSnapshot) => void +): () => void { + snapshotListeners.add(listener) + return () => snapshotListeners.delete(listener) +} + +function enqueueWrite(operation: () => T): Promise { + const pending = writeQueue.then(operation, operation) + writeQueue = pending.then( + () => undefined, + () => undefined ) + return pending } -export function readPendingBrowserIdentityMigrationNotice(userDataPath: string): string[] | null { - const current = readBrowserIdentityModeRecord(userDataPath) - if (current.migrationNoticePending !== true) { - return null - } - return current.migratedNativeProfileIds ?? [] -} - -export function clearBrowserIdentityMigrationNotice(userDataPath: string): boolean { - const current = readBrowserIdentityModeRecord(userDataPath) - if (current.migrationNoticePending !== true) { - return false - } - writeBrowserIdentityModeRecord(userDataPath, { - ...current, - migrationNoticePending: undefined +export function setBrowserIdentityMode( + mode: BrowserUserAgentMode +): Promise { + return enqueueWrite(() => { + const store = requireModeStore() + const current = store.snapshot + if (current.configuredMode === null) { + return { + ok: false, + error: { + code: 'browser_identity_reset_required', + message: `Browser identity data is ${current.state}; reset it before choosing a mode.` + }, + identity: current + } + } + const record: BrowserIdentityModeRecord = { + version: BROWSER_IDENTITY_MODE_VERSION, + mode, + explicitSelection: true, + migrationNoticePending: false + } + try { + writeBrowserIdentityModeRecord(store.userDataPath, record) + } catch (error) { + return { + ok: false, + error: { + code: 'browser_identity_write_failed', + message: error instanceof Error ? error.message : String(error) + }, + identity: current + } + } + const identity: BrowserIdentityModeSnapshot = { + state: 'valid', + appliedMode: current.appliedMode, + configuredMode: mode, + explicitSelection: true, + migrationNoticePending: false, + restartRequired: mode !== current.appliedMode + } + store.snapshot = identity + launchMigrationNoticePending = false + migrationNoticeDegraded = false + notifySnapshotListeners(identity) + return { ok: true, identity } }) - return true +} + +export function markBrowserIdentityMigrationNoticePending( + userDataPath: string, + degraded: boolean +): Promise { + return enqueueWrite(() => { + if (!modeStore) { + initializeBrowserIdentityModeStore(userDataPath) + } + const store = requireModeStore() + if (store.userDataPath !== userDataPath) { + throw new Error('Browser identity mode store userData path changed') + } + const current = store.snapshot + launchMigrationNoticePending = true + migrationNoticeDegraded ||= degraded + if (current.configuredMode === null) { + return false + } + const record: BrowserIdentityModeRecord = { + version: BROWSER_IDENTITY_MODE_VERSION, + mode: current.configuredMode, + explicitSelection: current.explicitSelection, + migrationNoticePending: true + } + try { + writeBrowserIdentityModeRecord(userDataPath, record) + } catch (error) { + console.error('[browser-identity] Could not persist retired profile notice:', error) + return false + } + store.snapshot = { + state: 'valid', + appliedMode: current.appliedMode, + configuredMode: current.configuredMode, + explicitSelection: current.explicitSelection, + migrationNoticePending: true, + restartRequired: current.restartRequired + } + notifySnapshotListeners(store.snapshot) + return true + }) +} + +export function resetBrowserIdentityModeStoreForTests(): void { + modeStore = null + writeQueue = Promise.resolve() + snapshotListeners.clear() + migrationNoticeDegraded = false + launchMigrationNoticePending = false } diff --git a/src/main/browser/browser-identity-mode-store.test.ts b/src/main/browser/browser-identity-mode-store.test.ts new file mode 100644 index 00000000000..bef98abd6ba --- /dev/null +++ b/src/main/browser/browser-identity-mode-store.test.ts @@ -0,0 +1,130 @@ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ failWrite: false })) + +vi.mock('../durable-file-write', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + writeFileDurableSync: (...args: Parameters) => { + if (mocks.failWrite) { + throw new Error('disk refused identity write') + } + actual.writeFileDurableSync(...args) + } + } +}) + +import { + BROWSER_IDENTITY_MODE_FILE, + BROWSER_IDENTITY_MODE_VERSION, + getBrowserIdentityModeSnapshot, + initializeBrowserIdentityModeStore, + resetBrowserIdentityModeStoreForTests, + setBrowserIdentityMode +} from './browser-identity-mode-record' + +function makeUserData(mode: 'clean' | 'native' = 'clean'): string { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-browser-identity-store-')) + writeFileSync( + join(userDataPath, BROWSER_IDENTITY_MODE_FILE), + JSON.stringify({ + version: BROWSER_IDENTITY_MODE_VERSION, + mode, + explicitSelection: false, + migrationNoticePending: true + }), + 'utf8' + ) + return userDataPath +} + +describe('browser identity mode store', () => { + beforeEach(() => { + mocks.failWrite = false + resetBrowserIdentityModeStoreForTests() + }) + + it('durably commits an explicit selection before reporting restart state', async () => { + const userDataPath = makeUserData() + initializeBrowserIdentityModeStore(userDataPath) + + await expect(setBrowserIdentityMode('native')).resolves.toEqual({ + ok: true, + identity: { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'native', + explicitSelection: true, + migrationNoticePending: false, + restartRequired: true + } + }) + expect(JSON.parse(readFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), 'utf8'))).toEqual( + { + version: BROWSER_IDENTITY_MODE_VERSION, + mode: 'native', + explicitSelection: true, + migrationNoticePending: false + } + ) + }) + + it('serializes concurrent read-modify-write updates', async () => { + const userDataPath = makeUserData() + initializeBrowserIdentityModeStore(userDataPath) + + const first = setBrowserIdentityMode('native') + const second = setBrowserIdentityMode('clean') + + await expect(first).resolves.toMatchObject({ ok: true }) + await expect(second).resolves.toMatchObject({ ok: true }) + expect(getBrowserIdentityModeSnapshot()).toMatchObject({ + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: true, + restartRequired: false + }) + }) + + it('returns a structured error and keeps both values unchanged after a failed write', async () => { + initializeBrowserIdentityModeStore(makeUserData()) + mocks.failWrite = true + + await expect(setBrowserIdentityMode('native')).resolves.toEqual({ + ok: false, + error: { + code: 'browser_identity_write_failed', + message: 'disk refused identity write' + }, + identity: { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: false, + migrationNoticePending: true, + restartRequired: false + } + }) + expect(getBrowserIdentityModeSnapshot()).toMatchObject({ + appliedMode: 'clean', + configuredMode: 'clean' + }) + }) + + it('refuses ordinary updates while the record is unhealthy', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-browser-identity-store-')) + writeFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), '{bad json', 'utf8') + initializeBrowserIdentityModeStore(userDataPath) + + await expect(setBrowserIdentityMode('native')).resolves.toMatchObject({ + ok: false, + error: { code: 'browser_identity_reset_required' }, + identity: { state: 'corrupt', configuredMode: null, appliedMode: 'clean' } + }) + expect(readFileSync(join(userDataPath, BROWSER_IDENTITY_MODE_FILE), 'utf8')).toBe('{bad json') + }) +}) diff --git a/src/main/browser/browser-session-persisted-profile-validation.ts b/src/main/browser/browser-session-persisted-profile-validation.ts index e42344e40b5..f2845d28379 100644 --- a/src/main/browser/browser-session-persisted-profile-validation.ts +++ b/src/main/browser/browser-session-persisted-profile-validation.ts @@ -23,38 +23,28 @@ export function isValidPersistedBrowserSessionProfile( ) } -function hasRetiredUserAgentMode(profile: BrowserSessionProfile): boolean { - return Object.hasOwn(profile, 'userAgentMode') -} - -function withoutRetiredUserAgentMode(profile: BrowserSessionProfile): BrowserSessionProfile { - if (!hasRetiredUserAgentMode(profile)) { - return profile - } - const migrated = { ...profile } - Reflect.deleteProperty(migrated, 'userAgentMode') - return migrated -} - -export function migrateRetiredBrowserSessionProfileUserAgentModes( - profiles: BrowserSessionProfile[], +export function inspectRetiredBrowserSessionProfileUserAgentModes( + profiles: readonly unknown[], activeOrcaProfileId: string -): { profiles: BrowserSessionProfile[]; nativeProfileIds: string[]; changed: boolean } { - // Why: JSON arrays may contain scalars or null even though the persisted type says profiles. - const inspectableProfiles = profiles.filter( - (profile) => profile !== null && typeof profile === 'object' - ) - const nativeProfileIds = inspectableProfiles - .filter((profile) => isValidPersistedBrowserSessionProfile(profile, activeOrcaProfileId)) - .filter((profile) => Reflect.get(profile, 'userAgentMode') === 'native') - .map((profile) => profile.id) - return { - profiles: inspectableProfiles.map(withoutRetiredUserAgentMode), - nativeProfileIds, - changed: - inspectableProfiles.length !== profiles.length || - inspectableProfiles.some(hasRetiredUserAgentMode) +): { noticePending: boolean; degraded: boolean } { + let noticePending = false + let degraded = false + for (const profile of profiles) { + if (!isValidPersistedBrowserSessionProfile(profile, activeOrcaProfileId)) { + noticePending = true + degraded = true + continue + } + if (!Object.hasOwn(profile, 'userAgentMode')) { + continue + } + noticePending = true + const mode = Reflect.get(profile, 'userAgentMode') + if (mode !== 'clean' && mode !== 'native') { + degraded = true + } } + return { noticePending, degraded } } function isProfileOwnedSessionPartition( diff --git a/src/main/browser/browser-session-registry.ts b/src/main/browser/browser-session-registry.ts index ca81f7a0d97..667195e7ef3 100644 --- a/src/main/browser/browser-session-registry.ts +++ b/src/main/browser/browser-session-registry.ts @@ -28,7 +28,7 @@ import { } from './browser-session-partition-policies' import { isValidPersistedBrowserSessionProfile, - migrateRetiredBrowserSessionProfileUserAgentModes + inspectRetiredBrowserSessionProfileUserAgentModes } from './browser-session-persisted-profile-validation' import { clearBrowserRoutePartitionPolicies, @@ -39,7 +39,7 @@ import { invalidateBrowserSessionProxyApplication } from './browser-session-prox import { retireFailedBrowserSessionProfile } from './browser-session-profile-retirement' import { cancelBrowserWebAuthnAccountRequestsForSession } from './browser-webauthn-account-picker' import { getCanonicalUserDataPath } from '../persistence/loading-store/user-data-path' -import { recordRetiredNativeBrowserProfiles } from './browser-identity-mode-record' +import { markBrowserIdentityMigrationNoticePending } from './browser-identity-mode-record' export type BrowserSessionRegistryProfileOptions = { orcaProfileId: string @@ -110,17 +110,16 @@ class BrowserSessionRegistry { // Why re-read defaultSource: the constructor may run before app.isReady() (userData path unavailable), so loadPersistedSource() returned null. initializeBrowserSessionsFromPersistedState(): void { const meta = this.loadPersistedMeta() - const migration = migrateRetiredBrowserSessionProfileUserAgentModes( + const migration = inspectRetiredBrowserSessionProfileUserAgentModes( meta.profiles, this.activeOrcaProfileId ) - // Record the notice first so a metadata-write failure cannot erase the user's retired choice. - const noticePersisted = recordRetiredNativeBrowserProfiles( - getCanonicalUserDataPath(), - migration.nativeProfileIds - ) - if (migration.changed && noticePersisted) { - this.persistMeta({ profiles: migration.profiles }) + if (migration.noticePending) { + // Why scoped: identity persistence must never reject browser-session startup. + void markBrowserIdentityMigrationNoticePending( + getCanonicalUserDataPath(), + migration.degraded + ).catch((error) => console.error('[browser-identity] Migration notice failed:', error)) } if (meta.defaultSource) { const current = this.profiles.get('default') @@ -128,8 +127,8 @@ class BrowserSessionRegistry { this.profiles.set('default', { ...current, source: meta.defaultSource }) } } - if (migration.profiles.length > 0) { - this.hydrateFromPersisted(migration.profiles) + if (meta.profiles.length > 0) { + this.hydrateFromPersisted(meta.profiles) } // Why: nothing else installs policies on the default partition (hydrate skips it), so without this its guest permissions would be denied. diff --git a/src/main/browser/browser-session-user-agent-migration-inspection.test.ts b/src/main/browser/browser-session-user-agent-migration-inspection.test.ts new file mode 100644 index 00000000000..499b341e265 --- /dev/null +++ b/src/main/browser/browser-session-user-agent-migration-inspection.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest' +import { getOrcaProfileBrowserSessionPartition } from '../../shared/orca-profiles' +import { inspectRetiredBrowserSessionProfileUserAgentModes } from './browser-session-persisted-profile-validation' + +const ORCA_PROFILE_ID = 'local-default' + +function profileWithMode(mode: unknown): Record { + const id = '11111111-1111-4111-8111-111111111111' + return { + id, + scope: 'isolated', + partition: getOrcaProfileBrowserSessionPartition(ORCA_PROFILE_ID, id), + label: 'Existing', + source: null, + userAgentMode: mode + } +} + +describe('retired browser profile identity inspection', () => { + it('detects an inspectable old choice without removing its bytes', () => { + const profile = profileWithMode('native') + + expect( + inspectRetiredBrowserSessionProfileUserAgentModes([profile], ORCA_PROFILE_ID) + ).toEqual({ noticePending: true, degraded: false }) + expect(profile.userAgentMode).toBe('native') + }) + + it.each([[null], [42], ['broken'], [profileWithMode('unexpected')]])( + 'turns malformed metadata into a degraded notice without throwing', + (entry) => { + expect(() => + inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID) + ).not.toThrow() + expect( + inspectRetiredBrowserSessionProfileUserAgentModes([entry], ORCA_PROFILE_ID) + ).toEqual({ noticePending: true, degraded: true }) + } + ) +}) diff --git a/src/main/ipc/browser-session-profile-ipc.ts b/src/main/ipc/browser-session-profile-ipc.ts index bf144e178fa..90121104031 100644 --- a/src/main/ipc/browser-session-profile-ipc.ts +++ b/src/main/ipc/browser-session-profile-ipc.ts @@ -17,17 +17,17 @@ import type { BrowserSessionProfileScope } from '../../shared/browser-workspace-types' import { - clearBrowserIdentityMigrationNotice, - readPendingBrowserIdentityMigrationNotice + getBrowserIdentityModeStatus, + setBrowserIdentityMode } from '../browser/browser-identity-mode-record' -import { getCanonicalUserDataPath } from '../persistence' +import { normalizeBrowserUserAgentMode } from '../../shared/browser-user-agent-mode' export function registerBrowserSessionProfileHandlers(): void { ipcMain.removeHandler('browser:session:listProfiles') ipcMain.removeHandler('browser:session:createProfile') ipcMain.removeHandler('browser:session:deleteProfile') - ipcMain.removeHandler('browser:session:readUserAgentMigrationNotice') - ipcMain.removeHandler('browser:session:clearUserAgentMigrationNotice') + ipcMain.removeHandler('browser:identity:get') + ipcMain.removeHandler('browser:identity:set') ipcMain.removeHandler('browser:session:importCookies') ipcMain.removeHandler('browser:session:resolvePartition') @@ -51,18 +51,18 @@ export function registerBrowserSessionProfileHandlers(): void { } ) - ipcMain.handle('browser:session:readUserAgentMigrationNotice', (event): string[] | null => { + ipcMain.handle('browser:identity:get', (event) => { if (!isTrustedBrowserRenderer(event.sender)) { return null } - return readPendingBrowserIdentityMigrationNotice(getCanonicalUserDataPath()) + return getBrowserIdentityModeStatus() }) - ipcMain.handle('browser:session:clearUserAgentMigrationNotice', (event): boolean => { + ipcMain.handle('browser:identity:set', async (event, mode: unknown) => { if (!isTrustedBrowserRenderer(event.sender)) { - return false + return null } - return clearBrowserIdentityMigrationNotice(getCanonicalUserDataPath()) + return setBrowserIdentityMode(normalizeBrowserUserAgentMode(mode)) }) ipcMain.handle( diff --git a/src/main/persistence/applying-settings/settings-update.ts b/src/main/persistence/applying-settings/settings-update.ts index c27879d6ca6..e8a080763da 100644 --- a/src/main/persistence/applying-settings/settings-update.ts +++ b/src/main/persistence/applying-settings/settings-update.ts @@ -39,7 +39,6 @@ import { buildWorkspaceDirHistoryForUpdate, stripRetiredGlobalSettings } from './terminal-settings-migrations' -import { normalizeBrowserUserAgentMode } from '../../../shared/browser-user-agent-mode' export type SettingsMutationOperations = { state: PersistedState @@ -77,11 +76,6 @@ export function updateSettings( if ('agentSkillSharingEnabled' in updates) { sanitizedUpdates.agentSkillSharingEnabled = updates.agentSkillSharingEnabled === true } - if ('browserUserAgentMode' in updates) { - sanitizedUpdates.browserUserAgentMode = normalizeBrowserUserAgentMode( - updates.browserUserAgentMode - ) - } if ('nestedWorkerMaxDepth' in updates) { sanitizedUpdates.nestedWorkerMaxDepth = resolveNestedWorkerMaxDepth({ nestedWorkerMaxDepth: updates.nestedWorkerMaxDepth diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts index 4c463959bba..ebb1d19f9ad 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.test.ts @@ -35,3 +35,11 @@ describe('retired Agents sidebar setting', () => { expect(normalized.agentsSidebarMigratedFromExperimental).toBe(true) }) }) + +describe('retired browser identity setting', () => { + it('retains the unknown value byte-for-byte instead of normalizing it into live settings', () => { + const normalized = normalizeLegacyProfile({ browserUserAgentMode: 'future-choice' }) + + expect(Reflect.get(normalized, 'browserUserAgentMode')).toBe('future-choice') + }) +}) diff --git a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts index 3066cacb0d1..90678e9f822 100644 --- a/src/main/persistence/loading-store/normalize-loaded-global-settings.ts +++ b/src/main/persistence/loading-store/normalize-loaded-global-settings.ts @@ -12,7 +12,6 @@ import { readLegacySidekickFlag } from '../applying-settings/onboarding-normaliz import type { PersistedState } from '../../../shared/persisted-state-types' import type { PreparedLoadedTerminalSettings } from './prepare-loaded-terminal-settings' import type { PreparedLoadedProfileSettings } from './prepare-loaded-profile-settings' -import { normalizeBrowserUserAgentMode } from '../../../shared/browser-user-agent-mode' export function normalizeLoadedGlobalSettings( parsed: PersistedState, @@ -108,7 +107,6 @@ export function normalizeLoadedGlobalSettings( terminalQuickCommands: normalizeTerminalQuickCommands(parsed.settings?.terminalQuickCommands), terminalCustomThemes: normalizeTerminalCustomThemes(parsed.settings?.terminalCustomThemes), appIcon: normalizeAppIconId(parsed.settings?.appIcon), - browserUserAgentMode: normalizeBrowserUserAgentMode(parsed.settings?.browserUserAgentMode), mobilePairingCustomAddress, mobilePairingCustomAddresses, // Why: persisted settings may be hand-edited or from older builds; keep tray-minimize false unless stored value is true. diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 780f40fb373..67d8e63c167 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -1,6 +1,7 @@ import { defineMethod } from '../core' import { BrowserTarget } from '../schemas' import { + BrowserIdentitySet, Check, Drag, Element, @@ -34,8 +35,22 @@ import { import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-schema' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' import { CertificateProceed } from '../../../../shared/rpc-contract/browser-core-params' +import { + getBrowserIdentityModeStatus, + setBrowserIdentityMode +} from '../../../browser/browser-identity-mode-record' export const BROWSER_CORE_METHODS = [ + defineMethod({ + name: 'browser.identity.get', + params: null, + handler: () => getBrowserIdentityModeStatus() + }), + defineMethod({ + name: 'browser.identity.set', + params: BrowserIdentitySet, + handler: async ({ mode }) => setBrowserIdentityMode(mode) + }), defineMethod({ name: 'browser.snapshot', params: BrowserTarget, diff --git a/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts b/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts new file mode 100644 index 00000000000..ea8bfe8f25f --- /dev/null +++ b/src/main/runtime/rpc/methods/browser-identity-rpc.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + get: vi.fn(() => ({ identity: { state: 'missing' }, migrationNotice: null })), + set: vi.fn(async () => ({ ok: true })) +})) + +vi.mock('../../../browser/browser-identity-mode-record', () => ({ + getBrowserIdentityModeStatus: mocks.get, + setBrowserIdentityMode: mocks.set +})) + +import type { OrcaRuntimeService } from '../../orca-runtime' +import { RpcDispatcher } from '../dispatcher' +import type { RpcRequest } from '../core' +import { BROWSER_CORE_METHODS } from './browser-core' + +function request(method: string, params?: unknown): RpcRequest { + return { id: 'identity-1', authToken: 'token', method, params } +} + +describe('browser identity RPC', () => { + it('serves the host-local identity snapshot', async () => { + const runtime = { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS }) + + const response = await dispatcher.dispatch(request('browser.identity.get')) + + expect(response).toMatchObject({ ok: true, result: { migrationNotice: null } }) + expect(mocks.get).toHaveBeenCalledTimes(1) + }) + + it('commits a host-local identity selection', async () => { + const runtime = { getRuntimeId: () => 'runtime-1' } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS }) + + await dispatcher.dispatch(request('browser.identity.set', { mode: 'native' })) + + expect(mocks.set).toHaveBeenCalledWith('native') + }) +}) diff --git a/src/main/runtime/rpc/methods/browser-schemas.ts b/src/main/runtime/rpc/methods/browser-schemas.ts index 034fee898ad..32040e7b558 100644 --- a/src/main/runtime/rpc/methods/browser-schemas.ts +++ b/src/main/runtime/rpc/methods/browser-schemas.ts @@ -1,6 +1,7 @@ // Why: browser schemas stay separate from handler registration so both sides // remain under the line cap and dispatch wiring stays scannable. export { + BrowserIdentitySet, Check, ClipboardWrite, CookieDelete, diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index 55ee1571e97..d2c95a32fbb 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -68,10 +68,10 @@ describe('browser RPC methods', () => { }) }) - it('ignores the retired profile user-agent field from older clients', () => { - expect( + it('rejects the retired profile user-agent field with changed-semantics guidance', () => { + expect(() => ProfileCreate.parse({ label: 'Google', scope: 'isolated', userAgentMode: 'native' }) - ).toEqual({ label: 'Google', scope: 'isolated' }) + ).toThrow('browser_profile_user_agent_mode_is_now_app_wide') }) it('routes core browser automation commands to the runtime server', async () => { diff --git a/src/main/server/serve-stdout-boundary.test.ts b/src/main/server/serve-stdout-boundary.test.ts index b59227e932d..38d59cf8d47 100644 --- a/src/main/server/serve-stdout-boundary.test.ts +++ b/src/main/server/serve-stdout-boundary.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { reserveServeStdoutForReadiness } from './serve-stdout-boundary' +import { + emitServeBrowserIdentityActionLine, + reserveServeStdoutForReadiness +} from './serve-stdout-boundary' describe('reserveServeStdoutForReadiness', () => { it('routes console diagnostics to stderr', () => { @@ -18,3 +21,45 @@ describe('reserveServeStdoutForReadiness', () => { expect(target.error.mock.calls).toEqual([['debug'], ['info'], ['log']]) }) }) + +describe('emitServeBrowserIdentityActionLine', () => { + it.each([ + { + state: 'valid' as const, + migrationNotice: { degraded: false }, + expected: 'choose Cleaned or Native' + }, + { + state: 'valid' as const, + migrationNotice: { degraded: true }, + expected: 'old choice could not be inspected' + }, + { state: 'corrupt' as const, migrationNotice: null, expected: 'reset it explicitly' }, + { state: 'future' as const, migrationNotice: null, expected: 'update Orca' } + ])('writes one stderr action for $state', ({ state, migrationNotice, expected }) => { + const write = vi.fn() + const identity = + state === 'valid' + ? { + state, + appliedMode: 'clean' as const, + configuredMode: 'clean' as const, + explicitSelection: false, + migrationNoticePending: true, + restartRequired: false + } + : { + state, + appliedMode: 'clean' as const, + configuredMode: null, + explicitSelection: null, + migrationNoticePending: null, + restartRequired: false as const + } + + emitServeBrowserIdentityActionLine({ identity, migrationNotice }, { write }) + + expect(write).toHaveBeenCalledTimes(1) + expect(write).toHaveBeenCalledWith(expect.stringContaining(expected)) + }) +}) diff --git a/src/main/server/serve-stdout-boundary.ts b/src/main/server/serve-stdout-boundary.ts index b4a44bb51ef..b5010abc2d7 100644 --- a/src/main/server/serve-stdout-boundary.ts +++ b/src/main/server/serve-stdout-boundary.ts @@ -1,4 +1,7 @@ +import type { BrowserIdentityModeStatus } from '../../shared/browser-user-agent-mode' + type DiagnosticConsole = Pick +type StderrTarget = Pick export function reserveServeStdoutForReadiness(target: DiagnosticConsole = console): void { // Why: stdout is the serve readiness API; route incidental diagnostics to stderr so JSON stays parseable. @@ -7,3 +10,25 @@ export function reserveServeStdoutForReadiness(target: DiagnosticConsole = conso target.info = writeDiagnostic target.log = writeDiagnostic } + +export function emitServeBrowserIdentityActionLine( + status: BrowserIdentityModeStatus, + target: StderrTarget = process.stderr +): void { + let action: string | null = null + if (status.identity.state === 'future') { + action = 'browser identity data is from a newer version; update Orca' + } else if ( + status.identity.state === 'corrupt' || + status.identity.state === 'unreadable' + ) { + action = `browser identity data is ${status.identity.state}; reset it explicitly` + } else if (status.migrationNotice?.degraded) { + action = 'an old choice could not be inspected; choose Cleaned or Native' + } else if (status.migrationNotice) { + action = 'browser identity changed to app-wide; choose Cleaned or Native' + } + if (action) { + target.write(`[browser-identity] action required: ${action}\n`) + } +} diff --git a/src/main/startup/browser-process-user-agent-ordering.test.ts b/src/main/startup/browser-process-user-agent-ordering.test.ts index d58717544fa..ebc924fcfab 100644 --- a/src/main/startup/browser-process-user-agent-ordering.test.ts +++ b/src/main/startup/browser-process-user-agent-ordering.test.ts @@ -144,9 +144,15 @@ vi.mock('./gpu-lifecycle') vi.mock('./main-process-state', () => ({ mainProcessState: {} })) vi.mock('./synthetic-title-runtime') vi.mock('../browser/browser-identity-mode-record', () => ({ - readBrowserIdentityModeRecord: (path: string) => { + initializeBrowserIdentityModeStore: (path: string) => { mocks.events.push(`read-mode:${path}`) - return { version: 1, mode: 'clean' } + return { + state: 'valid', + appliedMode: 'clean', + configuredMode: 'clean', + explicitSelection: true, + migrationNoticePending: false + } } })) diff --git a/src/main/startup/main-process-preflight.ts b/src/main/startup/main-process-preflight.ts index 0d62d91cdf3..139dd7bbdfa 100644 --- a/src/main/startup/main-process-preflight.ts +++ b/src/main/startup/main-process-preflight.ts @@ -87,7 +87,7 @@ import { maybeApplyGpuFallbackForThisLaunch, registerGpuLifecycleHandlers } from import { mainProcessState as state } from './main-process-state' import { initializeSyntheticTitleRuntime } from './synthetic-title-runtime' import { initializeBrowserProcessUserAgent } from '../browser/browser-process-user-agent' -import { readBrowserIdentityModeRecord } from '../browser/browser-identity-mode-record' +import { initializeBrowserIdentityModeStore } from '../browser/browser-identity-mode-record' export type MainProcessPreflightOptions = { focusExistingWindow: () => void @@ -186,7 +186,9 @@ export function runMainProcessPreflight(options: MainProcessPreflightOptions): b app.setName(state.devInstanceIdentity.appName) } // Why: renderer and worker defaults are process-global and must be fixed before any session exists. - initializeBrowserProcessUserAgent(readBrowserIdentityModeRecord(getCanonicalUserDataPath()).mode) + initializeBrowserProcessUserAgent( + initializeBrowserIdentityModeStore(getCanonicalUserDataPath()).appliedMode + ) state.startupDiagnosticsEnabled = isStartupDiagnosticsEnabled() if (state.startupDiagnosticsEnabled) { logStartupDiagnostic('before-single-instance-lock', { diff --git a/src/main/startup/main-process-ready-foundation.ts b/src/main/startup/main-process-ready-foundation.ts index 9f7901c8713..3c0e01fe09e 100644 --- a/src/main/startup/main-process-ready-foundation.ts +++ b/src/main/startup/main-process-ready-foundation.ts @@ -49,7 +49,6 @@ import { updateGpuAccelerationAboutPanel } from './gpu-lifecycle' import { reconcileManagedWslCliRegistrations } from '../cli/wsl-cli-registration-reconciliation' import { createWslCliReconciliationStartupBarrier } from './wsl-cli-reconciliation-startup-barrier' import { isAgentStatusHooksEnabled } from '../agent-hooks/managed-agent-hook-controls' -import { updateBrowserIdentityMode } from '../browser/browser-identity-mode-record' export async function initializeReadyFoundation(): Promise { logStartupMilestone('app-ready') @@ -200,8 +199,6 @@ export async function initializeReadyFoundation(): Promise { canonicalUserDataPath, store.getSettings().electronHttp1CompatibilityMode === true ) - // Why: the tiny pre-ready record mirrors this normal app setting for the next launch. - updateBrowserIdentityMode(canonicalUserDataPath, store.getSettings().browserUserAgentMode) // Why: apply initial fallback WSL distro from store settings for global git/CLI calls. setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null) store.onSettingsChanged((updates, settings) => { @@ -211,9 +208,6 @@ export async function initializeReadyFoundation(): Promise { settings.electronHttp1CompatibilityMode === true ) } - if ('browserUserAgentMode' in updates) { - updateBrowserIdentityMode(canonicalUserDataPath, settings.browserUserAgentMode) - } if ('terminalWindowsWslDistro' in updates) { // Why: synchronize fallback WSL distro updates to runner. setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null) diff --git a/src/main/startup/main-process-ready-identity-write.test.ts b/src/main/startup/main-process-ready-identity-write.test.ts index 3b671f88d7c..0fcefe2fc54 100644 --- a/src/main/startup/main-process-ready-identity-write.test.ts +++ b/src/main/startup/main-process-ready-identity-write.test.ts @@ -144,43 +144,21 @@ vi.mock('./main-process-runtime-launch', () => ({ })) import { initializeMainProcessReady } from './main-process-ready' -import { getBrowserIdentityPersistenceFailure } from '../browser/browser-identity-mode-record' - -describe('ready-phase identity write failure', () => { +describe('ready-phase browser identity authority', () => { beforeEach(() => { mocks.openMainWindow.mockClear() mocks.runtimeRpcStart.mockClear() + mocks.writeFileAtomically.mockClear() mocks.state.isServeMode = false }) - it('still reaches the desktop window when the identity sidecar write fails', async () => { - await expect( - initializeMainProcessReady({ - openMainWindow: mocks.openMainWindow, - handleMacAppActivation: vi.fn() - }) - ).resolves.toBeUndefined() - expect(mocks.openMainWindow).toHaveBeenCalledTimes(1) - expect(getBrowserIdentityPersistenceFailure()).toContain('read-only userData') + it('does not mirror the active Orca profile identity over the process-wide sidecar', async () => { + await initializeMainProcessReady({ + openMainWindow: mocks.openMainWindow, + handleMacAppActivation: vi.fn() + }) + + expect(mocks.writeFileAtomically).not.toHaveBeenCalled() }) - it('still reaches serve RPC startup and reports the write failure on stderr', async () => { - mocks.state.isServeMode = true - const stderr = vi.spyOn(console, 'error').mockImplementation(() => {}) - try { - await expect( - initializeMainProcessReady({ - openMainWindow: mocks.openMainWindow, - handleMacAppActivation: vi.fn() - }) - ).resolves.toBeUndefined() - expect(mocks.runtimeRpcStart).toHaveBeenCalledTimes(1) - expect(stderr).toHaveBeenCalledWith( - expect.stringContaining('[browser-identity]'), - expect.stringContaining('read-only userData') - ) - } finally { - stderr.mockRestore() - } - }) }) diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index fdedf310cbb..3be9d2d8f71 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -39,6 +39,8 @@ import { triggerStartupNotificationRegistration } from '../ipc/startup-notificat import { startDesktopPushService } from './main-process-push-startup' import { mainProcessState as state } from './main-process-state' import { logStartupMilestone } from './startup-diagnostics' +import { emitServeBrowserIdentityActionLine } from '../server/serve-stdout-boundary' +import { getBrowserIdentityModeStatus } from '../browser/browser-identity-mode-record' type RuntimeService = NonNullable @@ -207,6 +209,7 @@ async function launchServeMode( // Why: serve deletes worktrees too, and the history GC that normally drains delete tombstones is // armed from the main window — without this, a quit mid-removal leaks the tree until a desktop launch. scheduleAllPendingHistoryTreeRemovals() + emitServeBrowserIdentityActionLine(getBrowserIdentityModeStatus()) await printServeReady(serveOptions) } diff --git a/src/preload/api/browser-api.ts b/src/preload/api/browser-api.ts index 7e44bac2eb6..5aff6728abf 100644 --- a/src/preload/api/browser-api.ts +++ b/src/preload/api/browser-api.ts @@ -1,4 +1,9 @@ import type { BrowserSetAnnotationViewportBridgeArgs } from '../../shared/browser-annotation-viewport-bridge' +import type { + BrowserIdentityModeSetResult, + BrowserIdentityModeStatus, + BrowserUserAgentMode +} from '../../shared/browser-user-agent-mode' import type { BrowserClientPageMetadataParams, BrowserClientPageMetadataPublishOutcome @@ -143,8 +148,8 @@ export type BrowserApi = { scope: BrowserSessionProfileScope label: string }) => Promise - sessionReadUserAgentMigrationNotice: () => Promise - sessionClearUserAgentMigrationNotice: () => Promise + identityGet: () => Promise + identitySet: (mode: BrowserUserAgentMode) => Promise sessionDeleteProfile: (args: { profileId: string }) => Promise sessionImportCookies: (args: { profileId: string }) => Promise sessionResolvePartition: (args: { profileId: string | null }) => Promise diff --git a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts index 782fea3bc56..0e07440f245 100644 --- a/src/preload/api/browser-bridge-page-interaction-and-sessions.ts +++ b/src/preload/api/browser-bridge-page-interaction-and-sessions.ts @@ -1,5 +1,6 @@ import { ipcRenderer } from 'electron' import type { PreloadApi } from '../api-types' +import type { BrowserUserAgentMode } from '../../shared/browser-user-agent-mode' export const browserPageInteractionAndSessionsApi = { onContextMenuRequested: ( @@ -119,10 +120,8 @@ export const browserPageInteractionAndSessionsApi = { ipcRenderer.invoke('browser:prepareSshWorkspacePartition', args), sessionCreateProfile: (args: { scope: 'default' | 'isolated' | 'imported'; label: string }) => ipcRenderer.invoke('browser:session:createProfile', args), - sessionReadUserAgentMigrationNotice: (): Promise => - ipcRenderer.invoke('browser:session:readUserAgentMigrationNotice'), - sessionClearUserAgentMigrationNotice: (): Promise => - ipcRenderer.invoke('browser:session:clearUserAgentMigrationNotice'), + identityGet: () => ipcRenderer.invoke('browser:identity:get'), + identitySet: (mode: BrowserUserAgentMode) => ipcRenderer.invoke('browser:identity:set', mode), sessionDeleteProfile: (args: { profileId: string }): Promise => ipcRenderer.invoke('browser:session:deleteProfile', args), sessionImportCookies: (args: { profileId: string }) => diff --git a/src/renderer/src/app-shell/use-app-shell-services.ts b/src/renderer/src/app-shell/use-app-shell-services.ts index e969609c268..1eee41663df 100644 --- a/src/renderer/src/app-shell/use-app-shell-services.ts +++ b/src/renderer/src/app-shell/use-app-shell-services.ts @@ -17,6 +17,7 @@ import { useOsc52ClipboardDefaultOnNotice } from '../components/terminal-pane/os import { useWebSessionTabsSync } from '../runtime/web-session-tabs-sync' import { useLocalStructuredSessionTabsSync } from '../runtime/local-structured-session-tabs-sync' import { useRemoteRuntimeRecoveryTriggers } from '../runtime/use-remote-runtime-recovery-triggers' +import { useBrowserIdentityMigrationNotice } from '../components/browser-pane/browser-user-agent-migration-notice' /** * App-level subscriptions that must outlive any individual surface. Each one is here because @@ -48,4 +49,5 @@ export function useAppShellServices(options: { floatingPanelVisible: boolean }): useLargeTextControlPaste() usePrimarySelectionPaste(primarySelectionMiddleClickPaste) useOsc52ClipboardDefaultOnNotice(persistedUIReady) + useBrowserIdentityMigrationNotice() } diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 4c6ab94f010..98cf1ffe4d0 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -17,6 +17,8 @@ const ROOT_SURFACES_PATH = 'src/renderer/src/app-shell/AppRootSurfaces.tsx' const LAZY_MODAL_MOUNTS_PATH = 'src/renderer/src/app-shell/use-lazy-modal-mounts.ts' const SESSION_PERSISTENCE_PATH = 'src/renderer/src/app-shell/use-app-session-persistence.ts' const PERSISTED_UI_WRITER_PATH = 'src/renderer/src/app-shell/use-persisted-ui-writer.ts' +const BROWSER_GUEST_SESSION_PATH = + 'src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts' describe('renderer startup runtime routing', () => { it('routes packaged terminal restore through the daemon adoption gate', () => { @@ -585,6 +587,13 @@ describe('renderer startup runtime routing', () => { expect(appSource).toContain(' { + expect(readSource(SHELL_SERVICES_PATH)).toContain('useBrowserIdentityMigrationNotice()') + expect(readSource(BROWSER_GUEST_SESSION_PATH)).not.toContain( + 'showPendingBrowserUserAgentMigrationNotice' + ) + }) + it('checkpoints activeView and all session snapshots through one beforeunload handler (#9002)', () => { const source = readSource(SESSION_PERSISTENCE_PATH) const checkpointStart = source.indexOf( diff --git a/src/renderer/src/components/browser-pane/browser-user-agent-migration-notice.ts b/src/renderer/src/components/browser-pane/browser-user-agent-migration-notice.ts index add9135ec52..4eb6d6dccf0 100644 --- a/src/renderer/src/components/browser-pane/browser-user-agent-migration-notice.ts +++ b/src/renderer/src/components/browser-pane/browser-user-agent-migration-notice.ts @@ -1,38 +1,46 @@ +import { useEffect } from 'react' import { toast } from 'sonner' import { translate } from '@/i18n/i18n' import { useAppStore } from '@/store' import { BROWSER_USER_AGENT_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' -let pendingNotice: Promise | null = null - -async function showPendingBrowserUserAgentMigrationNoticeImpl(): Promise { - const migratedProfileIds = await window.api.browser.sessionReadUserAgentMigrationNotice() - if (migratedProfileIds === null) { - return - } - toast.warning(translate('browser.userAgentMigration.title', 'Browser identity is now app-wide'), { - description: translate( - 'browser.userAgentMigration.description', - 'Per-profile user-agent settings were removed. Choose Cleaned or Native in Browser settings; changes take effect after a restart.' - ), - duration: Infinity, - action: { - label: translate('browser.userAgentMigration.openSettings', 'Open Settings'), - onClick: () => { - const store = useAppStore.getState() - store.openSettingsPage() - store.openSettingsTarget({ - pane: 'browser', - repoId: null, - sectionId: BROWSER_USER_AGENT_SETTINGS_TARGET_ID - }) +export function useBrowserIdentityMigrationNotice(): void { + useEffect(() => { + void window.api.browser + .identityGet() + .then((status) => { + if (!status?.migrationNotice) { + return + } + const description = status.migrationNotice.degraded + ? translate( + 'browser.userAgentMigration.degradedDescription', + 'An old browser identity choice could not be inspected. Choose Cleaned or Native in Browser settings; changes take effect after a restart.' + ) + : translate( + 'browser.userAgentMigration.description', + 'Per-profile user-agent settings were removed. Choose Cleaned or Native in Browser settings; changes take effect after a restart.' + ) + toast.warning( + translate('browser.userAgentMigration.title', 'Browser identity is now app-wide'), + { + description, + duration: Infinity, + action: { + label: translate('browser.userAgentMigration.openSettings', 'Open Settings'), + onClick: () => { + const store = useAppStore.getState() + store.openSettingsPage() + store.openSettingsTarget({ + pane: 'browser', + repoId: null, + sectionId: BROWSER_USER_AGENT_SETTINGS_TARGET_ID + }) + } + } + } + ) } - } - }) - await window.api.browser.sessionClearUserAgentMigrationNotice() -} - -export function showPendingBrowserUserAgentMigrationNotice(): Promise { - pendingNotice ??= showPendingBrowserUserAgentMigrationNoticeImpl() - return pendingNotice + .catch(() => {}) + }, []) } diff --git a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts index d53d2b76f64..4623b817b2a 100644 --- a/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts +++ b/src/renderer/src/components/browser-pane/host-guest/browser-page-webview-guest-session.ts @@ -21,7 +21,6 @@ import type { BrowserPageRecoveryNavigationValidation, BrowserTabPageState } from '../describe-page/browser-page-types' -import { showPendingBrowserUserAgentMigrationNotice } from '../browser-user-agent-migration-notice' export type BrowserPageWebviewGuestSessionArgs = { webview: Electron.WebviewTag @@ -102,7 +101,6 @@ export function createBrowserPageWebviewGuestSession({ .then((registered) => { if (registered) { registeredWebContentsIds.set(browserTabId, webContentsId) - void showPendingBrowserUserAgentMigrationNotice().catch(() => {}) return true } return null diff --git a/src/renderer/src/components/settings/BrowserPane.tsx b/src/renderer/src/components/settings/BrowserPane.tsx index 66bc6bac285..7cc9e3b3413 100644 --- a/src/renderer/src/components/settings/BrowserPane.tsx +++ b/src/renderer/src/components/settings/BrowserPane.tsx @@ -253,7 +253,7 @@ export function BrowserPane({ ) : null} {showUserAgent ? ( - + ) : null} {showLinkRouting ? ( diff --git a/src/renderer/src/components/settings/BrowserUserAgentSetting.test.tsx b/src/renderer/src/components/settings/BrowserUserAgentSetting.test.tsx new file mode 100644 index 00000000000..00420f868b7 --- /dev/null +++ b/src/renderer/src/components/settings/BrowserUserAgentSetting.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TooltipProvider } from '../ui/tooltip' +import { BrowserUserAgentSetting } from './BrowserUserAgentSetting' + +const identityGet = vi.fn() + +describe('BrowserUserAgentSetting', () => { + beforeEach(() => { + identityGet.mockReset() + Object.defineProperty(window, 'api', { + configurable: true, + value: { browser: { identityGet } } + }) + }) + + afterEach(cleanup) + + it('never substitutes the local identity while Remote Settings is focused', () => { + identityGet.mockResolvedValue({ + identity: { configuredMode: 'native', appliedMode: 'native' }, + migrationNotice: null + }) + + render( + + + + ) + + expect(identityGet).not.toHaveBeenCalled() + expect(screen.getByText(/manage browser identity on the remote host/i)).toBeTruthy() + expect(screen.queryByRole('radiogroup')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/settings/BrowserUserAgentSetting.tsx b/src/renderer/src/components/settings/BrowserUserAgentSetting.tsx index ad5c2c7bba6..ff73e5a90f7 100644 --- a/src/renderer/src/components/settings/BrowserUserAgentSetting.tsx +++ b/src/renderer/src/components/settings/BrowserUserAgentSetting.tsx @@ -1,24 +1,133 @@ -import type { GlobalSettings } from '../../../../shared/global-settings-types' -import type { BrowserUserAgentMode } from '../../../../shared/browser-user-agent-mode' +import { useEffect, useState } from 'react' +import type { + BrowserIdentityModeStatus, + BrowserUserAgentMode +} from '../../../../shared/browser-user-agent-mode' +import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../../shared/execution-host' import { BROWSER_USER_AGENT_SETTINGS_TARGET_ID } from '@/lib/settings-navigation-types' import { translate } from '@/i18n/i18n' import { SearchableSetting } from './SearchableSetting' import { SettingsRow, SettingsSegmentedControl } from './SettingsFormControls' type BrowserUserAgentSettingProps = { - settings: Pick - updateSettings: (updates: Partial) => void + hostId: ExecutionHostId } -export function BrowserUserAgentSetting({ - settings, - updateSettings -}: BrowserUserAgentSettingProps): React.JSX.Element { +export function BrowserUserAgentSetting({ hostId }: BrowserUserAgentSettingProps): React.JSX.Element { + const [status, setStatus] = useState(null) + const [error, setError] = useState(null) + const [saving, setSaving] = useState(false) const title = translate('settings.browser.userAgent.title', 'Browser identity') const description = translate( 'settings.browser.userAgent.description', 'Choose the user agent for every browser profile and page. Native mode disables Google sign-in. Changes take effect after a restart.' ) + const isLocal = hostId === LOCAL_EXECUTION_HOST_ID + + useEffect(() => { + setStatus(null) + setError(null) + if (!isLocal) { + return + } + let disposed = false + void window.api.browser + .identityGet() + .then((nextStatus) => { + if (!disposed) { + setStatus(nextStatus) + } + }) + .catch((reason) => { + if (!disposed) { + setError(reason instanceof Error ? reason.message : String(reason)) + } + }) + return () => { + disposed = true + } + }, [isLocal]) + + const setMode = (mode: BrowserUserAgentMode): void => { + setSaving(true) + setError(null) + void window.api.browser + .identitySet(mode) + .then((result) => { + if (!result) { + setError('Browser identity is unavailable.') + } else if (!result.ok) { + setError(result.error.message) + } else { + setStatus({ identity: result.identity, migrationNotice: null }) + } + }) + .catch((reason) => setError(reason instanceof Error ? reason.message : String(reason))) + .finally(() => setSaving(false)) + } + + let control: React.JSX.Element + if (!isLocal) { + control = ( + + {translate( + 'settings.browser.userAgent.remoteUnsupported', + 'Manage browser identity on the remote host with the Orca CLI.' + )} + + ) + } else if (!status) { + control = ( + + {error ?? translate('settings.browser.userAgent.loading', 'Loading…')} + + ) + } else if (status.identity.configuredMode === null) { + control = ( + + {translate( + 'settings.browser.userAgent.resetRequired', + 'Identity data must be reset explicitly before it can be changed.' + )} + + ) + } else { + control = ( +
+ + size="sm" + ariaLabel={title} + value={status.identity.configuredMode} + disabled={saving} + onChange={setMode} + options={[ + { + value: 'clean', + label: translate('settings.browser.userAgent.optionClean', 'Cleaned'), + tooltip: translate( + 'settings.browser.userAgent.optionCleanTooltip', + 'Removes Orca and Electron tokens to match imported Chrome sessions.' + ) + }, + { + value: 'native', + label: translate('settings.browser.userAgent.optionNative', 'Native'), + tooltip: translate( + 'settings.browser.userAgent.optionNativeTooltip', + "Keeps Electron's built-in identity for sites that reject the cleaned identity. Google sign-in is unavailable in Native mode." + ) + } + ]} + /> + {status.identity.restartRequired ? ( +
+ {translate('settings.browser.userAgent.restartRequired', 'Restart required')} +
+ ) : null} + {error ?
{error}
: null} +
+ ) + } return ( - size="sm" - ariaLabel={title} - value={settings.browserUserAgentMode} - onChange={(browserUserAgentMode) => updateSettings({ browserUserAgentMode })} - options={[ - { - value: 'clean', - label: translate('settings.browser.userAgent.optionClean', 'Cleaned'), - tooltip: translate( - 'settings.browser.userAgent.optionCleanTooltip', - 'Removes Orca and Electron tokens to match imported Chrome sessions.' - ) - }, - { - value: 'native', - label: translate('settings.browser.userAgent.optionNative', 'Native'), - tooltip: translate( - 'settings.browser.userAgent.optionNativeTooltip', - "Keeps Electron's built-in identity for sites that reject the cleaned identity. Google sign-in is unavailable in Native mode." - ) - } - ]} - /> - } + control={control} /> ) diff --git a/src/shared/browser-user-agent-mode.ts b/src/shared/browser-user-agent-mode.ts index 252d1dcd1bb..ee1f8eddcb0 100644 --- a/src/shared/browser-user-agent-mode.ts +++ b/src/shared/browser-user-agent-mode.ts @@ -1,5 +1,39 @@ export type BrowserUserAgentMode = 'clean' | 'native' +export type BrowserIdentityModeSnapshot = + | { + state: 'missing' | 'valid' + appliedMode: BrowserUserAgentMode + configuredMode: BrowserUserAgentMode + explicitSelection: boolean + migrationNoticePending: boolean + restartRequired: boolean + } + | { + state: 'corrupt' | 'future' | 'unreadable' + appliedMode: 'clean' + configuredMode: null + explicitSelection: null + migrationNoticePending: null + restartRequired: false + } + +export type BrowserIdentityModeStatus = { + identity: BrowserIdentityModeSnapshot + migrationNotice: { degraded: boolean } | null +} + +export type BrowserIdentityModeSetResult = + | { ok: true; identity: BrowserIdentityModeSnapshot } + | { + ok: false + error: { + code: 'browser_identity_reset_required' | 'browser_identity_write_failed' + message: string + } + identity: BrowserIdentityModeSnapshot + } + export function normalizeBrowserUserAgentMode(mode: unknown): BrowserUserAgentMode { return mode === 'native' ? 'native' : 'clean' } diff --git a/src/shared/default-global-settings.ts b/src/shared/default-global-settings.ts index 20d65cf2582..c9b7a08928a 100644 --- a/src/shared/default-global-settings.ts +++ b/src/shared/default-global-settings.ts @@ -149,7 +149,6 @@ export function buildDefaultSettings(args: { terminalShortcutPolicy: 'orca-first', floatingTerminalEnabled: true, browserClientHostedRemoteEnabled: true, - browserUserAgentMode: 'clean', floatingTerminalDefaultedForAllUsers: true, floatingTerminalCwd: '~', floatingTerminalTrustedCwds: [], diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index 0e5e6b50da2..7ac923caaa7 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -41,7 +41,6 @@ import type { ExternalWorktreeVisibility, WorktreeVisibilitySourcePreferences } from './repo-types' -import type { BrowserUserAgentMode } from './browser-user-agent-mode' /** MiniMax account region used to select the quota endpoint. */ export type MiniMaxEndpoint = 'overseas' | 'cn' @@ -261,7 +260,6 @@ export type GlobalSettings = { /** Main-side new-page kill switch for paired Electron client-hosted browser placement. */ browserClientHostedRemoteEnabled?: boolean /** Process-wide browser identity selected for the next app launch. */ - browserUserAgentMode: BrowserUserAgentMode /** Routes SSH-workspace browser pages through the workspace's SSH host; off = plain local browsing. */ browserSshWorkspaceRoutingEnabled?: boolean /** Per-target opt-outs recorded from the routing error card's "Browse from this device instead". */ diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 99908de9b84..d21351eee5f 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -79,6 +79,7 @@ export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-title // offscreen backend). Advertised only when that backend is actually available, so // clients never fall back to a local desktop browser tab for a remote-owned page. export const BROWSER_HEADLESS_RUNTIME_CAPABILITY = 'browser.headless.v1' as const +export const BROWSER_IDENTITY_RUNTIME_CAPABILITY = 'browser.identity.v1' as const export const BROWSER_SCREENCAST_RUNTIME_CAPABILITY = 'browser.screencast.v1' as const export const BROWSER_CERTIFICATE_TRUST_RUNTIME_CAPABILITY = 'browser.certificate-trust.v1' as const // Why: older hosts discard browser.tabCreate's page field, so clients may only @@ -253,6 +254,7 @@ export const RUNTIME_CAPABILITIES = [ ORCHESTRATION_FEDERATION_RELEASE_ARCHIVE_RUNTIME_CAPABILITY, ORCHESTRATION_CONTRACT_RUNTIME_CAPABILITY, BROWSER_SCREENCAST_RUNTIME_CAPABILITY, + BROWSER_IDENTITY_RUNTIME_CAPABILITY, BROWSER_TAB_CREATE_KNOWN_ID_RUNTIME_CAPABILITY, BROWSER_CLIENT_HOST_RUNTIME_CAPABILITY, BROWSER_CLIENT_PAGE_METADATA_RUNTIME_CAPABILITY, diff --git a/src/shared/rpc-contract/browser-params.ts b/src/shared/rpc-contract/browser-params.ts index 638370ab9bf..9aaf52105db 100644 --- a/src/shared/rpc-contract/browser-params.ts +++ b/src/shared/rpc-contract/browser-params.ts @@ -134,12 +134,26 @@ export const TabProfileClone = BrowserTarget.extend({ profileId: requiredString('Missing required --profile') }) -export const ProfileCreate = z.object({ - label: requiredString('Missing required --label'), - // Strict enum so unknown scope values surface validation errors instead of being - // silently coerced to 'isolated' (pr-bug-scan finding from #1397). - scope: z.enum(['isolated', 'imported']) -}) +export const ProfileCreate = z + .object({ + label: requiredString('Missing required --label'), + // Strict enum so unknown scope values surface validation errors instead of being + // silently coerced to 'isolated' (pr-bug-scan finding from #1397). + scope: z.enum(['isolated', 'imported']), + userAgentMode: z.unknown().optional() + }) + .superRefine((value, context) => { + if (value.userAgentMode !== undefined) { + context.addIssue({ + code: 'custom', + message: 'browser_profile_user_agent_mode_is_now_app_wide', + path: ['userAgentMode'] + }) + } + }) + .transform(({ label, scope }) => ({ label, scope })) + +export const BrowserIdentitySet = z.object({ mode: z.enum(['clean', 'native']) }) export const ProfileDelete = z.object({ profileId: requiredString('Missing required --profile') })