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.
This commit is contained in:
Brennan Benson
2026-09-14 17:53:36 -07:00
parent fe99f62f83
commit 7db9c54b54
39 changed files with 1139 additions and 279 deletions
+5
View File
@@ -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: [
+70
View File
@@ -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()
+60
View File
@@ -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<void> {
const status = await client.call<RuntimeStatus>('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<string, string | boolean>): 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<string, CommandHandler> = {
'browser identity get': async (context) => {
await assertBrowserIdentitySupported(context)
const result = await context.client.call<BrowserIdentityModeStatus>('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<BrowserIdentityModeSetResult>('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}.`
)
}
}
+12
View File
@@ -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 <clean|native> [--json]',
allowedFlags: [...GLOBAL_FLAGS, 'mode']
},
{
path: ['open-url'],
summary: 'Open a URL on the paired client that hosts this terminal',
@@ -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
})
})
})
+256 -91
View File
@@ -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<void> = 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<BrowserIdentityModeRecord>
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<T>(operation: () => T): Promise<T> {
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<BrowserIdentityModeSetResult> {
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<boolean> {
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
}
@@ -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<typeof import('../durable-file-write')>()
return {
...actual,
writeFileDurableSync: (...args: Parameters<typeof actual.writeFileDurableSync>) => {
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')
})
})
@@ -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(
+11 -12
View File
@@ -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.
@@ -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<string, unknown> {
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 })
}
)
})
+10 -10
View File
@@ -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(
@@ -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
@@ -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')
})
})
@@ -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.
@@ -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,
@@ -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')
})
})
@@ -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,
+3 -3
View File
@@ -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 () => {
+46 -1
View File
@@ -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))
})
})
+25
View File
@@ -1,4 +1,7 @@
import type { BrowserIdentityModeStatus } from '../../shared/browser-user-agent-mode'
type DiagnosticConsole = Pick<Console, 'debug' | 'error' | 'info' | 'log'>
type StderrTarget = Pick<NodeJS.WriteStream, 'write'>
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`)
}
}
@@ -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
}
}
}))
+4 -2
View File
@@ -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', {
@@ -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<void> {
logStartupMilestone('app-ready')
@@ -200,8 +199,6 @@ export async function initializeReadyFoundation(): Promise<void> {
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<void> {
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)
@@ -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()
}
})
})
@@ -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<typeof state.runtime>
@@ -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)
}
+7 -2
View File
@@ -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<BrowserSessionProfile | null>
sessionReadUserAgentMigrationNotice: () => Promise<string[] | null>
sessionClearUserAgentMigrationNotice: () => Promise<boolean>
identityGet: () => Promise<BrowserIdentityModeStatus | null>
identitySet: (mode: BrowserUserAgentMode) => Promise<BrowserIdentityModeSetResult | null>
sessionDeleteProfile: (args: { profileId: string }) => Promise<boolean>
sessionImportCookies: (args: { profileId: string }) => Promise<BrowserCookieImportResult>
sessionResolvePartition: (args: { profileId: string | null }) => Promise<string | null>
@@ -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<string[] | null> =>
ipcRenderer.invoke('browser:session:readUserAgentMigrationNotice'),
sessionClearUserAgentMigrationNotice: (): Promise<boolean> =>
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<boolean> =>
ipcRenderer.invoke('browser:session:deleteProfile', args),
sessionImportCookies: (args: { profileId: string }) =>
@@ -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()
}
@@ -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('<Toaster closeButton')
})
it('mounts the browser identity migration notice from the app shell, not guest registration', () => {
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(
@@ -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<void> | null = null
async function showPendingBrowserUserAgentMigrationNoticeImpl(): Promise<void> {
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<void> {
pendingNotice ??= showPendingBrowserUserAgentMigrationNoticeImpl()
return pendingNotice
.catch(() => {})
}, [])
}
@@ -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
@@ -253,7 +253,7 @@ export function BrowserPane({
) : null}
{showUserAgent ? (
<BrowserUserAgentSetting settings={settings} updateSettings={updateSettings} />
<BrowserUserAgentSetting hostId={settingsFocusedHostId} />
) : null}
{showLinkRouting ? (
@@ -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(
<TooltipProvider>
<BrowserUserAgentSetting hostId="runtime:remote-host" />
</TooltipProvider>
)
expect(identityGet).not.toHaveBeenCalled()
expect(screen.getByText(/manage browser identity on the remote host/i)).toBeTruthy()
expect(screen.queryByRole('radiogroup')).toBeNull()
})
})
@@ -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<GlobalSettings, 'browserUserAgentMode'>
updateSettings: (updates: Partial<GlobalSettings>) => void
hostId: ExecutionHostId
}
export function BrowserUserAgentSetting({
settings,
updateSettings
}: BrowserUserAgentSettingProps): React.JSX.Element {
export function BrowserUserAgentSetting({ hostId }: BrowserUserAgentSettingProps): React.JSX.Element {
const [status, setStatus] = useState<BrowserIdentityModeStatus | null>(null)
const [error, setError] = useState<string | null>(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 = (
<span className="text-xs text-muted-foreground">
{translate(
'settings.browser.userAgent.remoteUnsupported',
'Manage browser identity on the remote host with the Orca CLI.'
)}
</span>
)
} else if (!status) {
control = (
<span className="text-xs text-muted-foreground">
{error ?? translate('settings.browser.userAgent.loading', 'Loading…')}
</span>
)
} else if (status.identity.configuredMode === null) {
control = (
<span className="text-xs text-destructive">
{translate(
'settings.browser.userAgent.resetRequired',
'Identity data must be reset explicitly before it can be changed.'
)}
</span>
)
} else {
control = (
<div className="space-y-1 text-right">
<SettingsSegmentedControl<BrowserUserAgentMode>
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 ? (
<div className="text-[11px] text-muted-foreground">
{translate('settings.browser.userAgent.restartRequired', 'Restart required')}
</div>
) : null}
{error ? <div className="text-[11px] text-destructive">{error}</div> : null}
</div>
)
}
return (
<SearchableSetting
@@ -30,32 +139,7 @@ export function BrowserUserAgentSetting({
<SettingsRow
label={title}
description={description}
control={
<SettingsSegmentedControl<BrowserUserAgentMode>
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}
/>
</SearchableSetting>
)
+34
View File
@@ -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'
}
-1
View File
@@ -149,7 +149,6 @@ export function buildDefaultSettings(args: {
terminalShortcutPolicy: 'orca-first',
floatingTerminalEnabled: true,
browserClientHostedRemoteEnabled: true,
browserUserAgentMode: 'clean',
floatingTerminalDefaultedForAllUsers: true,
floatingTerminalCwd: '~',
floatingTerminalTrustedCwds: [],
-2
View File
@@ -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". */
+2
View File
@@ -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,
+20 -6
View File
@@ -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') })