mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
@@ -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: [
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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(
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {})
|
||||
}, [])
|
||||
}
|
||||
|
||||
-2
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -149,7 +149,6 @@ export function buildDefaultSettings(args: {
|
||||
terminalShortcutPolicy: 'orca-first',
|
||||
floatingTerminalEnabled: true,
|
||||
browserClientHostedRemoteEnabled: true,
|
||||
browserUserAgentMode: 'clean',
|
||||
floatingTerminalDefaultedForAllUsers: true,
|
||||
floatingTerminalCwd: '~',
|
||||
floatingTerminalTrustedCwds: [],
|
||||
|
||||
@@ -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". */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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') })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user