diff --git a/config/scripts/electron-vite-output-contract.test.ts b/config/scripts/electron-vite-output-contract.test.ts index d5f3ffa8764..f59f4a80f88 100644 --- a/config/scripts/electron-vite-output-contract.test.ts +++ b/config/scripts/electron-vite-output-contract.test.ts @@ -79,7 +79,7 @@ describe('Electron Vite output contract', () => { expect(output.chunkFileNames).toBe('chunks/[name]-[hash].js') }) - it('externalizes packaged dependencies but bundles the daemon xterm graph', () => { + it('externalizes packaged dependencies but bundles self-contained main dependencies', () => { const external = electronViteConfig.main?.build?.rollupOptions?.external if (typeof external !== 'function') { throw new Error('Expected main-process external predicate') @@ -91,7 +91,9 @@ describe('Electron Vite output contract', () => { expect(external('node:fs', undefined, false)).toBe(true) expect(external('@xterm/headless', undefined, false)).toBe(false) expect(external('@xterm/addon-serialize', undefined, false)).toBe(false) + expect(external('psl', undefined, false)).toBe(false) expect(external('zod', undefined, false)).toBe(false) + expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('psl') expect(electronViteConfig.main?.build?.externalizeDeps?.exclude).toContain('zod') }) diff --git a/config/scripts/run-codex-real-account-validation.mjs b/config/scripts/run-codex-real-account-validation.mjs index 53d237f22ee..9318f412ffe 100644 --- a/config/scripts/run-codex-real-account-validation.mjs +++ b/config/scripts/run-codex-real-account-validation.mjs @@ -35,7 +35,6 @@ const RESTRICTED_ENV_KEYS = [ 'HOMEPATH', 'CODEX_HOME', 'ORCA_CODEX_HOME', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_E2E_HOME_DIR', 'ORCA_E2E_USER_DATA_DIR', 'ORCA_USER_DATA_PATH', @@ -69,7 +68,7 @@ async function resolveRealPath(candidate) { } } -export function createValidationEnv(inheritedEnv, layout, options = {}) { +export function createValidationEnv(inheritedEnv, layout) { const env = { ...inheritedEnv } for (const key of RESTRICTED_ENV_KEYS) { delete env[key] @@ -81,12 +80,7 @@ export function createValidationEnv(inheritedEnv, layout, options = {}) { NODE_ENV: 'development', ORCA_E2E_HOME_DIR: layout.homeDir, ORCA_E2E_USER_DATA_DIR: layout.userDataDir, - ORCA_USER_DATA_PATH: layout.userDataDir, - // Why: flag OFF pins every codex spawn to an explicit managed CODEX_HOME, - // so native codex never resolves the OS profile — the only Windows - // configuration where strict zero-event containment is reachable. It also - // exercises the emergency kill-switch lane users fall back to. - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: options.systemDefaultRealHome === 'off' ? '0' : '1' + ORCA_USER_DATA_PATH: layout.userDataDir } } @@ -268,8 +262,7 @@ function parseArgs(argv) { primaryHome: os.homedir(), configTemplate: null, tempParent: null, - laneAwareContainment: false, - systemDefaultRealHome: 'on' + laneAwareContainment: false } for (let index = 0; index < argv.length; index += 1) { const arg = argv[index] @@ -299,14 +292,8 @@ function parseArgs(argv) { options.skipBuild = true } else if (arg === '--keep') { options.keep = true - } else if (arg === '--system-default-real-home') { - const value = readValue() - if (value !== 'on' && value !== 'off') { - throw new Error('--system-default-real-home must be "on" or "off"') - } - options.systemDefaultRealHome = value } else if (arg === '--lane-aware-containment') { - // Why: on Windows the flag-ON system-default lane cannot be env-sandboxed + // Why: on Windows the system-default real-home lane cannot be env-sandboxed // (native codex ignores USERPROFILE), so strict zero-event containment is // structurally unreachable there. This mode records codex's designed // volatile churn without aborting while every other real-home write stays @@ -314,7 +301,7 @@ function parseArgs(argv) { options.laneAwareContainment = true } else if (arg === '--help') { console.log( - 'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template ] [--temp-parent ] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report ]' + 'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template ] [--temp-parent ] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--report ]' ) process.exit(0) } else { @@ -480,7 +467,7 @@ async function main() { const reportPath = options.reportPath ?? path.join(os.tmpdir(), `orca-codex-real-account-${options.scenario}-${Date.now()}.json`) - const launchEnv = createValidationEnv(process.env, layout, options) + const launchEnv = createValidationEnv(process.env, layout) let app = null let tripwire = null const abortController = new AbortController() diff --git a/config/scripts/run-codex-real-account-validation.test.ts b/config/scripts/run-codex-real-account-validation.test.ts index 0b10dee6ee8..43b94983749 100644 --- a/config/scripts/run-codex-real-account-validation.test.ts +++ b/config/scripts/run-codex-real-account-validation.test.ts @@ -46,29 +46,9 @@ describe('Codex real-account validation harness', () => { expect(env.CODEX_HOME).toBeUndefined() expect(env.ORCA_CODEX_HOME).toBeUndefined() expect(env.ZDOTDIR).toBeUndefined() - expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1') expect(env.SAFE_VALUE).toBe('preserved') }) - it('pins the real-home flag off when the system-default lane is disabled', async () => { - const primaryHome = path.join(os.tmpdir(), 'orca-primary-home-sentinel') - const { layout, env } = runValidationModule<{ - layout: { tempRoot: string } - env: Record - }>( - ` - const { createValidationEnv, createValidationLayout } = await import(process.argv[1]) - const layout = await createValidationLayout({ primaryHome: process.argv[2] }) - const env = createValidationEnv({}, layout, { systemDefaultRealHome: 'off' }) - console.log(JSON.stringify({ layout, env })) - `, - [primaryHome] - ) - cleanupPaths.push(layout.tempRoot) - - expect(env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('0') - }) - it('records only fingerprints for system-default and managed auth', async () => { const { layout, snapshot } = runValidationModule<{ layout: { tempRoot: string } diff --git a/config/scripts/run-idle-cpu-benchmark.mjs b/config/scripts/run-idle-cpu-benchmark.mjs index baa061b3590..53cabd3380b 100644 --- a/config/scripts/run-idle-cpu-benchmark.mjs +++ b/config/scripts/run-idle-cpu-benchmark.mjs @@ -483,7 +483,6 @@ async function main() { HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ...(options.headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) } }) diff --git a/config/scripts/serve-headless-fresh-profile-pairing.mjs b/config/scripts/serve-headless-fresh-profile-pairing.mjs index 8865b841541..1cbfe92473a 100755 --- a/config/scripts/serve-headless-fresh-profile-pairing.mjs +++ b/config/scripts/serve-headless-fresh-profile-pairing.mjs @@ -55,7 +55,6 @@ Object.assign(childEnv, { ORCA_DEV_USER_DATA_PATH: profileDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ...(process.platform === 'linux' ? { ELECTRON_DISABLE_SANDBOX: process.env.ELECTRON_DISABLE_SANDBOX ?? '1' } : {}) diff --git a/config/scripts/verify-linux-wayland-gpu-sandbox.mjs b/config/scripts/verify-linux-wayland-gpu-sandbox.mjs index ef35b6f7f3d..a36f81db86b 100644 --- a/config/scripts/verify-linux-wayland-gpu-sandbox.mjs +++ b/config/scripts/verify-linux-wayland-gpu-sandbox.mjs @@ -219,7 +219,6 @@ async function runValidation(mode) { ORCA_DEV_USER_DATA_PATH: userDataPath, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ELECTRON_ENABLE_LOGGING: '1', ELECTRON_ENABLE_STACK_DUMPING: '1', ELECTRON_OZONE_PLATFORM_HINT: 'wayland', diff --git a/config/scripts/windows-apphang-repro/electron-dev-session.mjs b/config/scripts/windows-apphang-repro/electron-dev-session.mjs index 0aaead7237e..be09b97014b 100644 --- a/config/scripts/windows-apphang-repro/electron-dev-session.mjs +++ b/config/scripts/windows-apphang-repro/electron-dev-session.mjs @@ -63,7 +63,6 @@ export function launchDevApp({ cdpPort, userDataDir }) { ORCA_DEV_USER_DATA_PATH: userDataDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ORCA_SKIP_DEV_WEB_PREPARE: '1', ORCA_STARTUP_DIAGNOSTICS: '1', REMOTE_DEBUGGING_PORT: String(cdpPort), diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index f4247281300..3e0e59381a4 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -63,6 +63,7 @@ "../src/main/droid/hook-service.ts", "../src/main/gemini/hook-service.ts", "../src/main/grok/hook-service.ts", + "../src/main/grok/windows-grok-hook-script.ts", "../src/main/devin/hook-settings.ts", "../src/main/devin/hook-service.ts", "../src/main/devin/hook-config-json.ts", diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 4255504f9a1..90b908200c4 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -10,6 +10,7 @@ import packageJson from './package.json' with { type: 'json' } const BUNDLED_MAIN_DEPENDENCIES = new Set([ '@xterm/headless', '@xterm/addon-serialize', + 'psl', // Why: Windows NSIS deploys app.asar before external resources; bootstrap must // not race the later resources/node_modules copy. 'zod' diff --git a/mobile/src/accounts/mobile-accounts-route.test.ts b/mobile/src/accounts/mobile-accounts-route.test.ts index 03893215f2e..f938a98772c 100644 --- a/mobile/src/accounts/mobile-accounts-route.test.ts +++ b/mobile/src/accounts/mobile-accounts-route.test.ts @@ -45,7 +45,7 @@ describe('mobile accounts route', () => { navigateToHostStackRoute( harness.navigation, - { push }, + { push, replace: vi.fn() }, 'host/one', mobileAccountsRouteTarget('host/one') ) diff --git a/mobile/src/navigation/host-stack-navigation.test.ts b/mobile/src/navigation/host-stack-navigation.test.ts index dc3a94cfe7a..43848f35449 100644 --- a/mobile/src/navigation/host-stack-navigation.test.ts +++ b/mobile/src/navigation/host-stack-navigation.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { coordinateHostStackNavigation, hostStackHostRoute, + hostStackRouteHref, navigateToHostStackRoute, type HostStackNavigationState } from './host-stack-navigation' @@ -69,7 +70,7 @@ describe('host stack navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', TARGET) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -89,7 +90,12 @@ describe('host stack navigation', () => { rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] }) ) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(rootLayoutScopedState(committedHostState('host/one'))) expect(harness.navigation.dispatch).toHaveBeenCalledWith({ @@ -103,7 +109,12 @@ describe('host stack navigation', () => { it('survives the state emitted before the root navigator has hydrated', () => { const harness = navigationHarness(undefined) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) expect(() => harness.setState(undefined)).not.toThrow() expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -112,10 +123,30 @@ describe('host stack navigation', () => { expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1) }) + it('replaces through Expo Router when root state omits the mounted child stack', () => { + const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() + + navigateToHostStackRoute(harness.navigation, { push: vi.fn(), replace }, 'host/one', TARGET) + harness.setState({ + index: 0, + routes: [{ name: 'h', params: { hostId: 'host/one' } }] + }) + + expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).toHaveBeenCalledWith(hostStackRouteHref(TARGET)) + expect(harness.listenerCount()).toBe(0) + }) + it('abandons the transition when navigation leaves the host route it was waiting on', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState({ index: 0, routes: [{ name: 'h' }] }) harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState(committedHostState('host/one')) @@ -127,7 +158,12 @@ describe('host stack navigation', () => { it('ignores a different host whose id merely decodes badly', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET) + navigateToHostStackRoute( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host/one', + TARGET + ) harness.setState(committedHostState('100%')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -138,7 +174,7 @@ describe('host stack navigation', () => { const controller = navigateToHostStackRoute( harness.navigation, - { push: vi.fn() }, + { push: vi.fn(), replace: vi.fn() }, 'host/one', TARGET ) @@ -156,7 +192,7 @@ describe('host stack navigation', () => { it('retargets a still-pending transition to the same host instead of pushing twice', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - const router = { push } + const router = { push, replace: vi.fn() } const pending = coordinateHostStackNavigation( null, diff --git a/mobile/src/navigation/host-stack-navigation.ts b/mobile/src/navigation/host-stack-navigation.ts index 6f73dd3e57a..cb3b87cd98f 100644 --- a/mobile/src/navigation/host-stack-navigation.ts +++ b/mobile/src/navigation/host-stack-navigation.ts @@ -35,8 +35,14 @@ export type HostStackRootNavigation = { export type HostStackHostRoute = `/h/${string}` +export type HostStackRouteHref = Readonly<{ + pathname: `/h/${string}` + params: Readonly> +}> + export type HostStackRouter = { push: (route: HostStackHostRoute) => void + replace: (route: HostStackRouteHref) => void } export type HostStackNavigationController = Readonly<{ @@ -54,6 +60,10 @@ export function hostStackHostRoute(hostId: string): HostStackHostRoute { return `/h/${encodeURIComponent(hostId)}` } +export function hostStackRouteHref(target: HostStackRouteTarget): HostStackRouteHref { + return { pathname: `/h/${target.name}`, params: target.params } +} + // Why: the host is pushed as an encoded segment, so the committed route may hold // either form — an id with `/`, `#`, or `%` must still match its own push. function hostParamMatches(param: unknown, expectedHostId: string): boolean { @@ -144,6 +154,10 @@ export function navigateToHostStackRoute( } const hostStack = hostContainer && mountedHostStack(hostContainer, hostId) if (!hostStack) { + if (hostContainer && hostParamMatches(hostContainer.params?.hostId, hostId)) { + dispose() + router.replace(hostStackRouteHref(selectedTarget)) + } return } dispose() diff --git a/mobile/src/notifications/notification-route-coordination.test.ts b/mobile/src/notifications/notification-route-coordination.test.ts index e41df45a8a2..eab13fdc046 100644 --- a/mobile/src/notifications/notification-route-coordination.test.ts +++ b/mobile/src/notifications/notification-route-coordination.test.ts @@ -47,7 +47,12 @@ describe('notification route coordination', () => { const harness = navigationHarness(undefined) const push = vi.fn() - navigateToHostStackRoute(harness.navigation, { push }, target!.hostId, target!.sessionTarget!) + navigateToHostStackRoute( + harness.navigation, + { push, replace: vi.fn() }, + target!.hostId, + target!.sessionTarget! + ) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index 2680064c94b..c8e62c146cb 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -47,6 +47,7 @@ function overlayElement(tick: Tick): ReturnType { nativeChatStreamLive: tick.streamLive ?? false, nativeChatStreamScopeKey: tick.identity ?? 'tab-a', chatPending: [], + chatImagePreviewsByMessageId: {}, chatComposerText: '', setChatComposerText: vi.fn() } as unknown as MobileNativeChatController diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 4361c185d3c..699f8a1614b 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -82,6 +82,7 @@ export function MobileNativeChatOverlay({ onLoadEarlier={session.loadEarlier} onSend={images.sendNativeChat} pending={controller.chatPending} + imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} composerText={controller.chatComposerText} onComposerTextChange={controller.setChatComposerText} onAttachImage={() => void images.attachImage('library')} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 05763d59707..21a5a7c4aa1 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -50,8 +50,13 @@ vi.mock('./MobileAgentWorkingIndicator', () => ({ vi.mock('./MobileNativeChatComposer', async () => { const React = await import('react') return { - MobileNativeChatComposer: (props: { onSend: (text: string) => Promise }) => + MobileNativeChatComposer: (props: { + onSend: (text: string) => Promise + disabled?: boolean + placeholder?: string + }) => React.createElement('Composer', { + ...props, accessibilityLabel: 'Send message', onPress: () => props.onSend('hi') }) @@ -136,6 +141,10 @@ describe('MobileNativeChatView', () => { return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert') } + function composer(): ReactTestInstance { + return renderer!.root.find((node) => node.type === 'Composer') + } + function bannerText(): string { const [alert, ...rest] = banners() expect(rest).toHaveLength(0) @@ -197,4 +206,42 @@ describe('MobileNativeChatView', () => { expect(listIds()).toEqual(['a1', 'streaming']) }) + + it('keeps a visible lock through a subscribed-end lease blip', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + expect(composer().props.disabled).toBe(true) + + await update({ inputLockReason: null }) + expect(composer().props.disabled).toBe(true) + await act(async () => vi.advanceTimersByTime(300)) + await update({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + + expect(composer().props.disabled).toBe(true) + expect(composer().props.placeholder).toBe('Waiting for terminal…') + } finally { + vi.useRealTimers() + } + }) + + it('unlocks after the lease stays ready', async () => { + vi.useFakeTimers() + try { + await render({ inputLockReason: 'waiting' }) + await act(async () => vi.advanceTimersByTime(600)) + await update({ inputLockReason: null }) + await act(async () => vi.advanceTimersByTime(599)) + expect(composer().props.disabled).toBe(true) + + await act(async () => vi.advanceTimersByTime(1)) + + expect(composer().props.disabled).toBe(false) + expect(composer().props.placeholder).toBe('Message, @files, /commands') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index a35f3caed3c..478d36267a7 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -33,6 +33,8 @@ import { MobileNativeChatQuestion } from './MobileNativeChatQuestion' import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question' import type { MobileNativeChatStatus } from './use-mobile-native-chat-session' +const INPUT_LOCK_SETTLE_MS = 600 + /** Why the composer input is locked: the transport is disconnected, or the * terminal subscription has not acknowledged its input lease yet. */ export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting' @@ -59,6 +61,9 @@ type Props = { /** Optimistic queued sends (owned by the route so they survive view switches). */ /** Optimistic user echoes, including any ridden-along image preview URIs. */ pending: MobileNativeChatPendingItem[] + /** Local photo URIs retained when the authoritative transcript replaces an + * optimistic image bubble. */ + imagePreviewsByMessageId?: Record /** Controlled composer text (owned by the route so dictation can write to it). */ composerText: string onComposerTextChange: (text: string) => void @@ -123,6 +128,7 @@ export function MobileNativeChatView({ onLoadEarlier, onSend, pending, + imagePreviewsByMessageId, composerText, onComposerTextChange, onAttachImage, @@ -175,8 +181,14 @@ export function MobileNativeChatView({ // route-owned optimistic queued messages. Memoize on the same deps so the // downstream autoscroll effects/`renderItem` keep referential stability. const { data } = useMemo( - () => buildMobileNativeChatTransientData({ folded, streaming, pending }), - [folded, streaming, pending] + () => + buildMobileNativeChatTransientData({ + folded, + streaming, + pending, + imagePreviewsByMessageId + }), + [folded, streaming, pending, imagePreviewsByMessageId] ) // Follow the tail as the conversation grows and keep the newest message above @@ -250,20 +262,18 @@ export function MobileNativeChatView({ const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error) const showLoading = status === 'loading' && messages.length === 0 - // Composer-lock flicker guard: on a remote link, brief connState blips or lease - // hand-offs would otherwise toggle the lock placeholder on and off. Only surface - // a lock once it has held ~600ms; drop it instantly so unlocking stays snappy. + // A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled. const rawLockReason = inputLockReason ?? null + const rawLockHeld = rawLockReason !== null const [lockHeld, setLockHeld] = useState(false) useEffect(() => { - if (rawLockReason === null) { - setLockHeld(false) + if (rawLockHeld === lockHeld) { return } - const timer = setTimeout(() => setLockHeld(true), 600) + const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS) return () => clearTimeout(timer) - }, [rawLockReason]) - const lockReason = lockHeld ? rawLockReason : null + }, [lockHeld, rawLockHeld]) + const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null return ( diff --git a/mobile/src/session/mobile-image-source-picker.test.ts b/mobile/src/session/mobile-image-source-picker.test.ts index fbdd9eb5dec..35e3cff3b60 100644 --- a/mobile/src/session/mobile-image-source-picker.test.ts +++ b/mobile/src/session/mobile-image-source-picker.test.ts @@ -12,13 +12,28 @@ vi.mock('expo-file-system', () => ({ File: vi.fn() })) -import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker' +import { + ImageLibraryPermissionError, + pickMobileImage, + pickMobileImages, + type PickedMobileImage +} from './mobile-image-source-picker' const granted = { granted: true } as Awaited< ReturnType > const denied = { granted: false } as typeof granted +async function collectImages( + images: AsyncIterable +): Promise { + const collected: PickedMobileImage[] = [] + for await (const image of images) { + collected.push(image) + } + return collected +} + function fileFactory( bytes: Uint8Array, options?: { fileSize?: number; handleSize?: number | null; readError?: Error } @@ -68,6 +83,57 @@ describe('pickMobileImage', () => { expect(file.close).toHaveBeenCalledTimes(1) }) + it('returns every selected library photo in order', async () => { + const bytesByUri = new Map([ + ['file:///a.jpg', new Uint8Array([1])], + ['file:///b.jpg', new Uint8Array([2])], + ['file:///c.jpg', new Uint8Array([3])] + ]) + const createFile = vi.fn((uri: string) => { + const bytes = bytesByUri.get(uri)! + let read = false + return { + size: bytes.length, + open: () => ({ + size: bytes.length, + readBytes: () => { + if (read) { + return new Uint8Array() + } + read = true + return bytes + }, + close: vi.fn() + }) + } + }) + const launchLibrary = vi.fn().mockResolvedValue({ + canceled: false, + assets: [...bytesByUri].map(([uri, bytes]) => ({ uri, fileSize: bytes.length })) + }) + + const result = await collectImages( + pickMobileImages('library', { + requestLibraryPermission: vi.fn().mockResolvedValue(granted), + launchLibrary, + createFile + }) + ) + + expect(result.map((image) => image.uri)).toEqual([ + 'file:///a.jpg', + 'file:///b.jpg', + 'file:///c.jpg' + ]) + expect(launchLibrary).toHaveBeenCalledWith( + expect.objectContaining({ + allowsMultipleSelection: true, + orderedSelection: true, + selectionLimit: 0 + }) + ) + }) + it('throws when photo library permission is denied', async () => { await expect( pickMobileImage('library', { diff --git a/mobile/src/session/mobile-image-source-picker.ts b/mobile/src/session/mobile-image-source-picker.ts index bf9c529cc25..062085f7270 100644 --- a/mobile/src/session/mobile-image-source-picker.ts +++ b/mobile/src/session/mobile-image-source-picker.ts @@ -83,11 +83,12 @@ async function readUriAsBase64( } } -async function pickFromLibrary( +async function* pickFromLibrary( + multiple: boolean, requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync, launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const permission = await requestPermission() // Why: `granted` covers full + limited iOS access; only a hard denial blocks us. if (!permission.granted) { @@ -96,51 +97,84 @@ async function pickFromLibrary( const result = await launch({ mediaTypes: ['images'], base64: false, - allowsMultipleSelection: false, + allowsMultipleSelection: multiple, + ...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}), quality: 1 }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null - if (!base64) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.fileSize, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) } } -async function pickFromFiles( +async function* pickFromFiles( + multiple: boolean, launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync, createFile: MobileImageFileFactory = defaultMobileImageFileFactory -): Promise { +): AsyncGenerator { const result = await launch({ type: 'image/*', - multiple: false, + multiple, copyToCacheDirectory: true }) if (result.canceled) { - return null + return } - const asset = result.assets[0] - if (!asset?.uri) { - return null + for (const asset of result.assets) { + if (!asset.uri) { + continue + } + const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) + if (base64) { + yield { base64, uri: asset.uri } + } } - const base64 = await readUriAsBase64(asset.uri, asset.size, createFile) - return base64 ? { base64, uri: asset.uri } : null +} + +type MobileImagePickerDeps = { + readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync + readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync + readonly launchFiles?: typeof DocumentPicker.getDocumentAsync + readonly createFile?: MobileImageFileFactory +} + +function pickMobileImagesWithMode( + source: MobileImageSource, + multiple: boolean, + deps?: MobileImagePickerDeps +): AsyncIterable { + if (source === 'library') { + return pickFromLibrary( + multiple, + deps?.requestLibraryPermission, + deps?.launchLibrary, + deps?.createFile + ) + } + return pickFromFiles(multiple, deps?.launchFiles, deps?.createFile) } export async function pickMobileImage( source: MobileImageSource, - deps?: { - readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync - readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync - readonly launchFiles?: typeof DocumentPicker.getDocumentAsync - readonly createFile?: MobileImageFileFactory - } + deps?: MobileImagePickerDeps ): Promise { - if (source === 'library') { - return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile) + for await (const image of pickMobileImagesWithMode(source, false, deps)) { + return image } - return pickFromFiles(deps?.launchFiles, deps?.createFile) + return null +} + +export function pickMobileImages( + source: MobileImageSource, + deps?: MobileImagePickerDeps +): AsyncIterable { + return pickMobileImagesWithMode(source, true, deps) } diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts new file mode 100644 index 00000000000..4c0cee804c1 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { + findLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, + type PendingImagePreviewEcho +} from './mobile-native-chat-draft-reconcile' + +function userText(id: string, text: string): NativeChatMessage { + return { + id, + role: 'user', + blocks: [{ type: 'text', text }], + timestamp: null, + source: 'transcript' + } +} + +function pending(id: string, images: string[], expectedOccurrence = 1): PendingImagePreviewEcho { + return { id, text: '', images, expectedOccurrence, baselineTailMessageId: null } +} + +describe('mobile native chat image preview reconciliation', () => { + it('keeps separate adjacent image-only sends independently reconcilable', () => { + const landed = findLandedImagePreviewEchoes( + [ + userText('source-a', '[Image: source: /tmp/a.png]'), + userText('source-b', '[Image: source: /tmp/b.png]') + ], + [pending('pending-a', ['file:///a.jpg']), pending('pending-b', ['file:///b.jpg'], 2)] + ) + + expect(landed).toEqual([ + { pendingId: 'pending-a', messageId: 'source-a', images: ['file:///a.jpg'] }, + { pendingId: 'pending-b', messageId: 'source-b', images: ['file:///b.jpg'] } + ]) + }) + + it('waits for a complete multi-image turn as transcript source frames stream in', () => { + const entry = pending('pending', ['file:///a.jpg', 'file:///b.jpg']) + const sourceA = userText('source-a', '[Image: source: /tmp/a.png]') + const sourceB = userText('source-b', '[Image: source: /tmp/b.png]') + + expect(findLandedImagePreviewEchoes([sourceA], [entry])).toEqual([]) + expect(findLandedImagePreviewEchoes([sourceA, sourceB], [entry])).toEqual([]) + expect( + findLandedImagePreviewEchoes( + [sourceA, sourceB, userText('prompt', '[Image #1] [Image #2]')], + [entry] + ) + ).toEqual([ + { + pendingId: 'pending', + messageId: 'prompt', + images: ['file:///a.jpg', 'file:///b.jpg'] + } + ]) + }) + + it('moves an early standalone preview to the later folded prompt id', () => { + const sessionKey = 'host\0worktree\0tab\0session' + const previous = { [sessionKey]: { source: ['file:///a.jpg'] } } + const messages = [ + userText('source', '[Image: source: /tmp/a.png]'), + userText('prompt', '[Image #1]') + ] + + expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({ + [sessionKey]: { prompt: ['file:///a.jpg'] } + }) + }) +}) diff --git a/mobile/src/session/mobile-native-chat-draft-reconcile.ts b/mobile/src/session/mobile-native-chat-draft-reconcile.ts index 1af010ff503..360fd22587b 100644 --- a/mobile/src/session/mobile-native-chat-draft-reconcile.ts +++ b/mobile/src/session/mobile-native-chat-draft-reconcile.ts @@ -1,6 +1,7 @@ -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { isImageSourceUserTurn, + normalizeImageTranscriptMessages, stripImagePromptMarker } from './mobile-native-chat-image-transcript-markers' @@ -61,6 +62,149 @@ export function countImageSourceTurnsAfter( return count } +export type PendingImagePreviewEcho = { + id: string + text: string + images?: string[] + expectedOccurrence: number + baselineTailMessageId: string | null +} + +export type LandedImagePreviewEcho = { + pendingId: string + messageId: string + images: string[] +} + +const SENT_IMAGE_PREVIEW_LIMIT = 32 +const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8 + +export function mergeLandedImagePreviewEchoes( + previous: Record>, + sessionKey: string, + landed: readonly LandedImagePreviewEcho[] +): Record> { + const entries = Object.entries(previous[sessionKey] ?? {}) + for (const preview of landed) { + const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId) + if (existingIndex >= 0) { + entries.splice(existingIndex, 1) + } + entries.push([preview.messageId, preview.images]) + } + const next = { ...previous } + delete next[sessionKey] + next[sessionKey] = Object.fromEntries(entries.slice(-SENT_IMAGE_PREVIEW_LIMIT)) + for (const key of Object.keys(next).slice(0, -SENT_IMAGE_PREVIEW_SESSION_LIMIT)) { + delete next[key] + } + return next +} + +function imagePreviewReplacementMessageId( + messages: readonly NativeChatMessage[], + sourceIndex: number +): string | null { + const source = messages[sourceIndex] + if (!source || !isImageSourceUserTurn(source)) { + return null + } + let nextIndex = sourceIndex + 1 + while ( + messages[nextIndex]?.source === source.source && + isImageSourceUserTurn(messages[nextIndex]!) + ) { + nextIndex++ + } + const prompt = messages[nextIndex] + const firstText = prompt?.blocks.find((block) => block.type === 'text') + return prompt?.role === 'user' && + prompt.source === source.source && + firstText?.type === 'text' && + stripImagePromptMarker(firstText.text) !== firstText.text + ? prompt.id + : null +} + +/** Moves previews forward when a progressive source-only transcript frame later + * folds into the marker-prefixed prompt with a different authoritative id. */ +export function migrateImagePreviewMessageIds( + previous: Record>, + sessionKey: string, + messages: readonly NativeChatMessage[] +): Record> { + const sessionPreviews = previous[sessionKey] + if (!sessionPreviews) { + return previous + } + const messageIndexById = new Map(messages.map((message, index) => [message.id, index])) + let nextSession: Record | null = null + for (const [messageId, images] of Object.entries(sessionPreviews)) { + const sourceIndex = messageIndexById.get(messageId) + if (sourceIndex === undefined) { + continue + } + const replacementId = imagePreviewReplacementMessageId(messages, sourceIndex) + if (!replacementId) { + continue + } + nextSession ??= { ...sessionPreviews } + delete nextSession[messageId] + nextSession[replacementId] = [...(nextSession[replacementId] ?? []), ...images] + } + return nextSession ? { ...previous, [sessionKey]: nextSession } : previous +} + +/** Binds local preview URIs to the authoritative transcript turn that replaced + * the optimistic bubble. Host paths and marker-only Codex turns cannot render + * the phone-local photo without this handoff. */ +export function findLandedImagePreviewEchoes( + messages: readonly NativeChatMessage[], + entries: readonly PendingImagePreviewEcho[] +): LandedImagePreviewEcho[] { + const normalized = normalizeImageTranscriptMessages(messages) + const messageIndexById = new Map(normalized.map((message, index) => [message.id, index])) + const claimedMessageIds = new Set() + const landed: LandedImagePreviewEcho[] = [] + + for (const entry of entries) { + if (!entry.images?.length) { + continue + } + const targetText = entry.text.trim() + const candidates = normalized.filter((message) => { + if (message.role !== 'user') { + return false + } + if (targetText) { + return normalizedUserText(message) === targetText + } + const imageCount = message.blocks.filter(isImageRefBlock).length + return message.blocks.length === 0 || imageCount >= entry.images!.length + }) + const tailIndex = entry.baselineTailMessageId + ? messageIndexById.get(entry.baselineTailMessageId) + : -1 + const occurrenceIndex = Math.max(0, entry.expectedOccurrence - 1) + const candidate = targetText + ? candidates[occurrenceIndex] + : candidates.filter( + (message) => + tailIndex === undefined || (messageIndexById.get(message.id) ?? -1) > tailIndex + )[occurrenceIndex] + if ( + !candidate || + claimedMessageIds.has(candidate.id) || + (tailIndex !== undefined && (messageIndexById.get(candidate.id) ?? -1) <= tailIndex) + ) { + continue + } + claimedMessageIds.add(candidate.id) + landed.push({ pendingId: entry.id, messageId: candidate.id, images: entry.images }) + } + return landed +} + export function findLandedUnconfirmedSends( messages: readonly NativeChatMessage[], entries: readonly UnconfirmedSend[] diff --git a/mobile/src/session/mobile-native-chat-image-attachment.test.ts b/mobile/src/session/mobile-native-chat-image-attachment.test.ts index 57c3f67bd1c..e61116f3caa 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.test.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import type { RpcClient } from '../transport/rpc-client' import type { RpcResponse, RpcSuccess } from '../transport/types' -import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment' +import { uploadMobileNativeChatImages } from './mobile-native-chat-image-attachment' function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } } @@ -16,6 +16,10 @@ function methodNotFound(id: string): RpcResponse { } } +function failed(id: string, message: string): RpcResponse { + return { id, ok: false, error: { code: 'failed', message }, _meta: { runtimeId: 'r' } } +} + function clientWithResponses(responses: RpcResponse[]): Pick & { calls: { method: string; params: unknown }[] } { @@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick { +describe('uploadMobileNativeChatImages', () => { it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => { const client = clientWithResponses([ methodNotFound('start'), ok('save', '/tmp/orca-attach.png') ]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => 'conn-7', - pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'AAAA', uri: 'file:///photo.jpg' }]) }) - expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }) + expect(result).toEqual([{ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }]) // Native chat defers the paste to submit — nothing is sent to the terminal here. expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false) const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile') expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' }) }) + it('uploads all three selected images in picker order', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + ok('save-b', '/tmp/b.png'), + methodNotFound('start-c'), + ok('save-c', '/tmp/c.png') + ]) + + const order: string[] = [] + async function* pickImages() { + for (const image of [ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' }, + { base64: 'CCCC', uri: 'file:///c.jpg' } + ]) { + order.push(`read:${image.uri}`) + yield image + } + } + const result = await uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => 'conn-7', + pickImages, + onImageUploaded: (image) => order.push(`uploaded:${image.previewUri}`) + }) + + expect(result).toEqual([ + { path: '/tmp/a.png', previewUri: 'file:///a.jpg' }, + { path: '/tmp/b.png', previewUri: 'file:///b.jpg' }, + { path: '/tmp/c.png', previewUri: 'file:///c.jpg' } + ]) + expect(order).toEqual([ + 'read:file:///a.jpg', + 'uploaded:file:///a.jpg', + 'read:file:///b.jpg', + 'uploaded:file:///b.jpg', + 'read:file:///c.jpg', + 'uploaded:file:///c.jpg' + ]) + }) + it('returns null when the picker is cancelled and uploads nothing', async () => { const client = clientWithResponses([]) - const result = await uploadMobileNativeChatImage('library', { + const result = await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null) + pickImages: vi.fn().mockResolvedValue([]) }) - expect(result).toBeNull() + expect(result).toEqual([]) expect(client.calls).toEqual([]) }) + it('reports completed uploads before a later image fails', async () => { + const client = clientWithResponses([ + methodNotFound('start-a'), + ok('save-a', '/tmp/a.png'), + methodNotFound('start-b'), + failed('save-b', 'upload failed') + ]) + const onImageUploaded = vi.fn() + + await expect( + uploadMobileNativeChatImages('library', { + client, + getConnectionId: async () => null, + pickImages: vi.fn().mockResolvedValue([ + { base64: 'AAAA', uri: 'file:///a.jpg' }, + { base64: 'BBBB', uri: 'file:///b.jpg' } + ]), + onImageUploaded + }) + ).rejects.toThrow('upload failed') + expect(onImageUploaded).toHaveBeenCalledOnce() + expect(onImageUploaded).toHaveBeenCalledWith({ + path: '/tmp/a.png', + previewUri: 'file:///a.jpg' + }) + }) + it('falls back to an inline data uri for the preview when the picker omits a uri', async () => { const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')]) - const result = await uploadMobileNativeChatImage('files', { + const result = await uploadMobileNativeChatImages('files', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' }) + pickImages: vi.fn().mockResolvedValue([{ base64: 'BBBB' }]) }) - expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }) + expect(result).toEqual([{ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }]) }) it('signals upload start only after a real image is picked', async () => { const onUploadStart = vi.fn() const cancelledClient = clientWithResponses([]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client: cancelledClient, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue(null), + pickImages: vi.fn().mockResolvedValue([]), onUploadStart }) expect(onUploadStart).not.toHaveBeenCalled() const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')]) - await uploadMobileNativeChatImage('library', { + await uploadMobileNativeChatImages('library', { client, getConnectionId: async () => null, - pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }), + pickImages: vi.fn().mockResolvedValue([{ base64: 'CCCC', uri: 'file:///y.jpg' }]), onUploadStart }) expect(onUploadStart).toHaveBeenCalledTimes(1) diff --git a/mobile/src/session/mobile-native-chat-image-attachment.ts b/mobile/src/session/mobile-native-chat-image-attachment.ts index f81b9d2ad98..fc9a962bacf 100644 --- a/mobile/src/session/mobile-native-chat-image-attachment.ts +++ b/mobile/src/session/mobile-native-chat-image-attachment.ts @@ -13,34 +13,67 @@ export type PendingNativeChatImage = { readonly previewUri: string } -export type UploadNativeChatImageDeps = { +export function appendPendingNativeChatImages( + current: readonly PendingNativeChatImage[], + uploaded: readonly Omit[], + idCounter: { current: number } +): PendingNativeChatImage[] { + return [ + ...current, + ...uploaded.map((image) => { + idCounter.current += 1 + return { id: `img-${idCounter.current}`, ...image } + }) + ] +} + +export type UploadNativeChatImagesDeps = { readonly client: Pick readonly getConnectionId: () => Promise // Injected so this module stays free of expo/react-native imports (unit-testable). - readonly pickImage: (source: MobileImageSource) => Promise + readonly pickImages: ( + source: MobileImageSource + ) => + | Iterable + | AsyncIterable + | Promise | AsyncIterable> // Fired once the user has picked an image and the host upload is about to start — // lets the UI show the attach spinner only for the transfer, not the picker. readonly onUploadStart?: () => void + /** Retains each completed upload if a later image in the same selection fails. */ + readonly onImageUploaded?: (image: Omit) => void } /** Picks an image and uploads it to the host, returning the host path + a local * preview URI — but does NOT paste it into the terminal. Unlike the terminal * attach flow, native chat holds the image as a composer chip and rides it along * on submit (desktop parity), so the chip and the agent input never diverge. - * Returns null when the user cancels the picker. */ -export async function uploadMobileNativeChatImage( + * Returns an empty array when the user cancels the picker. */ +export async function uploadMobileNativeChatImages( source: MobileImageSource, - { client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps -): Promise | null> { - const picked = await pickImage(source) - if (!picked) { - return null + { + client, + getConnectionId, + pickImages, + onUploadStart, + onImageUploaded + }: UploadNativeChatImagesDeps +): Promise[]> { + const picked = await pickImages(source) + const uploaded: Omit[] = [] + let connectionId: string | null = null + for await (const image of picked) { + if (uploaded.length === 0) { + onUploadStart?.() + connectionId = await getConnectionId() + } + const path = await saveMobileClipboardImageAsTempFile(client, image.base64, { connectionId }) + // Prefer the picker's local URI for the thumbnail; fall back to an inline data + // URI when the source omitted one (RN renders both). + const previewUri = image.uri ?? `data:image/png;base64,${image.base64}` + const result = { path, previewUri } + uploaded.push(result) + onImageUploaded?.(result) } - onUploadStart?.() - const connectionId = await getConnectionId() - const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId }) - // Prefer the picker's local URI for the thumbnail; fall back to an inline data - // URI when the source omitted one (RN renders both). - const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}` - return { path, previewUri } + return uploaded } diff --git a/mobile/src/session/mobile-native-chat-image-send.test.ts b/mobile/src/session/mobile-native-chat-image-send.test.ts index 46ac4730f2e..cf1c59adf0f 100644 --- a/mobile/src/session/mobile-native-chat-image-send.test.ts +++ b/mobile/src/session/mobile-native-chat-image-send.test.ts @@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick { it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => { - const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)]) + const client = clientWithResponses([ + sendResult(true), + sendResult(true), + sendResult(true), + sendResult(true) + ]) const ok = await pasteMobileNativeChatImagePaths({ client, terminal: 'term-1', deviceToken: 'device-9', - imagePaths: ['/tmp/a.png', '/tmp/b.png'] + imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png'] }) expect(ok).toBe(true) - expect(client.calls).toHaveLength(3) + expect(client.calls).toHaveLength(4) // Leading Ctrl+U clears any stale input so a retry can't duplicate the image. expect(client.calls[0]).toEqual({ method: 'terminal.send', @@ -50,6 +55,7 @@ describe('pasteMobileNativeChatImagePaths', () => { }) expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~') expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~') + expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~') }) it('stops and reports failure as soon as a paste is rejected', async () => { diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts new file mode 100644 index 00000000000..b18136a9d47 --- /dev/null +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -0,0 +1,91 @@ +export type MobileNativeChatPendingMessage = { + id: string + text: string + expectedOccurrence: number + /** Local preview URIs carried by the send for its optimistic echo. */ + images?: string[] + baselineTailMessageId: string | null +} + +export type MobileNativeChatSendOrigin = { + draftKey: string + pendingKey: string | null + normalizedText: string + baselineOccurrences: number + baselineTailMessageId: string | null +} + +type PendingByKey = Record + +export function combineMobileNativeChatPending( + session: MobileNativeChatPendingMessage[], + waiting: readonly MobileNativeChatPendingMessage[] +): MobileNativeChatPendingMessage[] { + if (waiting.length === 0) { + return session + } + const sessionIds = new Set(session.map((item) => item.id)) + return [...session, ...waiting.filter((item) => !sessionIds.has(item.id))] +} + +export function appendMobileNativeChatPending( + previous: PendingByKey, + key: string, + id: string, + origin: MobileNativeChatSendOrigin, + text: string, + images?: string[] +): PendingByKey { + const current = previous[key] ?? [] + const earlierOutstanding = current.filter( + (pending) => + pending.text.trim() === origin.normalizedText && + pending.expectedOccurrence > origin.baselineOccurrences + ).length + const expectedImageEchoOrdinal = + current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1 + return { + ...previous, + [key]: [ + ...current, + { + id, + text, + expectedOccurrence: + origin.normalizedText === '' + ? expectedImageEchoOrdinal + : origin.baselineOccurrences + earlierOutstanding + 1, + baselineTailMessageId: origin.baselineTailMessageId, + ...(images?.length ? { images } : {}) + } + ] + } +} + +export function mergeWaitingSessionPending( + previous: PendingByKey, + sessionKey: string, + waiting: readonly MobileNativeChatPendingMessage[] +): PendingByKey { + const current = previous[sessionKey] ?? [] + const currentIds = new Set(current.map((item) => item.id)) + const moved = waiting.filter((item) => !currentIds.has(item.id)) + return moved.length > 0 ? { ...previous, [sessionKey]: [...current, ...moved] } : previous +} + +export function removeWaitingSessionPending( + previous: PendingByKey, + draftKey: string, + movedIds: ReadonlySet +): PendingByKey { + const remaining = (previous[draftKey] ?? []).filter((item) => !movedIds.has(item.id)) + if (remaining.length > 0) { + return { ...previous, [draftKey]: remaining } + } + if (!(draftKey in previous)) { + return previous + } + const next = { ...previous } + delete next[draftKey] + return next +} diff --git a/mobile/src/session/mobile-native-chat-render-data.test.ts b/mobile/src/session/mobile-native-chat-render-data.test.ts index b4a273d31ca..c67257ec24d 100644 --- a/mobile/src/session/mobile-native-chat-render-data.test.ts +++ b/mobile/src/session/mobile-native-chat-render-data.test.ts @@ -116,6 +116,36 @@ describe('buildMobileNativeChatTransientData', () => { expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }]) }) + it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => { + const folded = foldMobileNativeChatMessages([ + user('source', '[Image: source: /tmp/a.png]'), + user('prompt', '[Image #1] look at this') + ]) + const result = buildMobileNativeChatTransientData({ + folded, + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data).toHaveLength(1) + expect(result.data[0]?.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' }, + { type: 'text', text: 'look at this' } + ]) + }) + + it('restores the local preview onto a marker-only transcript turn', () => { + const result = buildMobileNativeChatTransientData({ + folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]), + streaming: null, + pending: [], + imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] } + }) + + expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }]) + }) + it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => { // Whether text streams at all is the gate's call // (`mobile-native-chat-streaming-gate.test.ts`); this only places it. diff --git a/mobile/src/session/mobile-native-chat-render-data.ts b/mobile/src/session/mobile-native-chat-render-data.ts index e11b9ac56f4..b3d4ba1c0fd 100644 --- a/mobile/src/session/mobile-native-chat-render-data.ts +++ b/mobile/src/session/mobile-native-chat-render-data.ts @@ -3,7 +3,7 @@ import { formatNativeChatEmptyStateCopy, type NativeChatEmptyStateCopy } from '../../../src/shared/native-chat-empty-state' -import type { NativeChatMessage } from '../../../src/shared/native-chat-types' +import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types' import { foldToolMessages } from './mobile-native-chat-blocks' import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers' import { stripNoiseMessages } from './mobile-native-chat-noise' @@ -55,15 +55,37 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat export function buildMobileNativeChatTransientData({ folded, streaming, - pending + pending, + imagePreviewsByMessageId }: { folded: NativeChatMessage[] /** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */ streaming: string | null pending: MobileNativeChatPendingItem[] + imagePreviewsByMessageId?: Record }): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } { + const renderedFolded = folded.map((message) => { + const previews = imagePreviewsByMessageId?.[message.id] + if (message.role !== 'user' || !previews?.length) { + return message + } + let previewIndex = 0 + const blocks = message.blocks.map((block) => { + if (!isImageRefBlock(block)) { + return block + } + const url = previews[previewIndex] + previewIndex += 1 + return url ? { ...block, url } : block + }) + while (previewIndex < previews.length) { + blocks.push({ type: 'image-ref', url: previews[previewIndex] }) + previewIndex += 1 + } + return { ...message, blocks } + }) const data: NativeChatMessage[] = [ - ...folded, + ...renderedFolded, ...(streaming ? [ { @@ -88,5 +110,5 @@ export function buildMobileNativeChatTransientData({ source: 'transcript' as const })) ] - return { folded, streaming, data } + return { folded: renderedFolded, streaming, data } } diff --git a/mobile/src/session/mobile-session-route.test.ts b/mobile/src/session/mobile-session-route.test.ts index 56b9fac7aa4..d9126475bc3 100644 --- a/mobile/src/session/mobile-session-route.test.ts +++ b/mobile/src/session/mobile-session-route.test.ts @@ -81,7 +81,7 @@ describe('mobile session route', () => { name: 'Fix #1' }) - navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target) + navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', target) expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index a1ed942316f..a9cd1201639 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -40,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({ composerText: '', setComposerText: vi.fn(), pending: [], + imagePreviewsByMessageId: {}, captureSendOrigin, readSeededLaunchDraft: () => null, readSeededLaunchDraftSeed: () => null, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 49d75409c05..5ed98925e9c 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -50,6 +50,7 @@ export type MobileNativeChatController = { chatComposerText: string setChatComposerText: Dispatch> chatPending: MobileNativeChatPendingMessage[] + chatImagePreviewsByMessageId: Record nativeChatSession: ReturnType nativeChatAgentWorking: boolean nativeChatStreamingText?: string @@ -160,6 +161,7 @@ export function useMobileNativeChatController(args: { composerText: chatComposerText, setComposerText: setChatComposerText, pending: chatPending, + imagePreviewsByMessageId: chatImagePreviewsByMessageId, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, @@ -326,6 +328,7 @@ export function useMobileNativeChatController(args: { chatComposerText, setChatComposerText, chatPending, + chatImagePreviewsByMessageId, nativeChatSession, nativeChatAgentWorking, nativeChatStreamingText, diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index bf3f94bd239..154ef3fb870 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -269,6 +269,7 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it("keeps an image-only echo when an unrelated text send's echo lands", async () => { @@ -345,6 +346,29 @@ describe('useMobileNativeChatDrafts', () => { ) ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] }) + }) + + it('hands a marker-only image preview to the authoritative user bubble', async () => { + await mount('a') + const origin = state?.captureSendOrigin('') + act(() => { + if (origin) { + state?.acceptSend(origin, '', ['file:///a.jpg']) + } + }) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + messages: [userTextMessage('u1', '[Image #1]')] + }) + ) + ) + + expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] }) }) it('does not reconcile a repeated send against an older identical turn', async () => { @@ -722,21 +746,59 @@ describe('useMobileNativeChatDrafts', () => { } }) - it('accepts and clears the first send before a provider session id exists', async () => { + it('preserves first-send images through session assignment and transcript replacement', async () => { await mount('a') await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null }))) - act(() => state?.setComposerText('start the session')) + const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg'] + act(() => state?.setComposerText('look')) - const origin = state?.captureSendOrigin('start the session') + const origin = state?.captureSendOrigin('look') expect(origin).toMatchObject({ pendingKey: null }) act(() => { if (origin) { - state?.clearDraftForSend(origin, 'start the session') - state?.acceptSend(origin, 'start the session') + state?.clearDraftForSend(origin, 'look') + state?.acceptSend(origin, 'look', images) } }) expect(state?.composerText).toBe('') + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' })) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]') + ] + }) + ) + ) + expect(state?.pending.map((pending) => pending.images)).toEqual([images]) + + await act(async () => + renderer?.update( + createElement(Harness, { + tabId: 'a', + sessionId: 'assigned', + messages: [ + userTextMessage('source-1', '[Image: source: /tmp/a.png]'), + userTextMessage('source-2', '[Image: source: /tmp/b.png]'), + userTextMessage('source-3', '[Image: source: /tmp/c.png]'), + userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look') + ] + }) + ) + ) expect(state?.pending).toEqual([]) + expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images }) }) }) diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index a43bc8b286f..9da4dea6a9b 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -3,35 +3,29 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types' import { countImageSourceTurnsAfter, countUserTextOccurrences, + findLandedImagePreviewEchoes, findLandedUnconfirmedSends, + mergeLandedImagePreviewEchoes, + migrateImagePreviewMessageIds, normalizedUserText, type UnconfirmedSend } from './mobile-native-chat-draft-reconcile' +import { + appendMobileNativeChatPending, + combineMobileNativeChatPending, + mergeWaitingSessionPending, + removeWaitingSessionPending, + type MobileNativeChatPendingMessage, + type MobileNativeChatSendOrigin +} from './mobile-native-chat-pending-echo' import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' -export type MobileNativeChatPendingMessage = { - id: string - text: string - expectedOccurrence: number - /** Local preview URIs of images ridden along on the send, rendered as thumbnails - * on the echo bubble so the sent photo shows before the transcript catches up. */ - images?: string[] - /** Transcript tail when sent — an image-only echo (no text to match) reconciles - * against new `[Image: source: …]` echo turns after this id, so pagination, - * agent replies, and unrelated text echoes can't clear it early. */ - baselineTailMessageId: string | null -} -export type MobileNativeChatSendOrigin = { - draftKey: string - pendingKey: string | null - normalizedText: string - baselineOccurrences: number - baselineTailMessageId: string | null -} +export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = [] +const NO_IMAGE_PREVIEWS: Record = {} // How long an ack-lost send waits for its transcript echo before the UI surfaces // that delivery remains unconfirmed. @@ -57,6 +51,9 @@ export function useMobileNativeChatDrafts(args: { composerText: string setComposerText: Dispatch> pending: MobileNativeChatPendingMessage[] + /** Phone-local previews rebound to the transcript message that replaced the + * optimistic echo, keyed by authoritative message id. */ + imagePreviewsByMessageId: Record captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null /** Launch-context text still believed to be parked on the agent's TUI input * line, or null once it has been declined or retired. Send paths size their @@ -91,6 +88,12 @@ export function useMobileNativeChatDrafts(args: { const [pendingBySession, setPendingBySession] = useState< Record >({}) + const [pendingWaitingForSession, setPendingWaitingForSession] = useState< + Record + >({}) + const [imagePreviewsBySession, setImagePreviewsBySession] = useState< + Record> + >({}) const pendingCounterRef = useRef(0) const messagesRef = useRef(messages) messagesRef.current = messages @@ -162,41 +165,21 @@ export function useMobileNativeChatDrafts(args: { const acceptSend = useCallback( (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => { - // Why: the first prompt can be sent before the provider reports a session - // id; wait for an id before keying an optimistic echo. - if (!origin.pendingKey) { + if (!origin.pendingKey && !images?.length) { return } - const pendingKey = origin.pendingKey pendingCounterRef.current += 1 - setPendingBySession((previous) => { - const current = previous[pendingKey] ?? NO_PENDING_MESSAGES - const earlierOutstanding = current.filter( - (pending) => - pending.text.trim() === origin.normalizedText && - pending.expectedOccurrence > origin.baselineOccurrences - ).length - // An empty-text send reconciles by image-echo ordinal: every outstanding - // send's ridden-along images echo as `[Image: source: …]` turns after - // this send's baseline tail, ahead of this send's own echo. - const expectedImageEchoOrdinal = - current.reduce( - (sum, pending) => - sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)), - 0 - ) + 1 - const pending: MobileNativeChatPendingMessage = { - id: `pending-${pendingCounterRef.current}`, - text, - expectedOccurrence: - origin.normalizedText === '' - ? expectedImageEchoOrdinal - : origin.baselineOccurrences + earlierOutstanding + 1, - baselineTailMessageId: origin.baselineTailMessageId, - ...(images && images.length > 0 ? { images } : {}) - } - return { ...previous, [pendingKey]: [...current, pending] } - }) + const id = `pending-${pendingCounterRef.current}` + const key = origin.pendingKey + if (key) { + setPendingBySession((previous) => + appendMobileNativeChatPending(previous, key, id, origin, text, images) + ) + } else { + setPendingWaitingForSession((previous) => + appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images) + ) + } }, [] ) @@ -255,9 +238,7 @@ export function useMobileNativeChatDrafts(args: { const landedSet = new Set(landed) unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry)) for (const entry of landed) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } }, [messages, draftKey, pendingKey]) @@ -266,21 +247,49 @@ export function useMobileNativeChatDrafts(args: { return () => { mountedRef.current = false for (const entry of unconfirmedRef.current) { - if (entry.deadline !== null) { - clearTimeout(entry.deadline) - } + clearTimeout(entry.deadline ?? undefined) } unconfirmedRef.current = [] } }, []) - const pending = pendingKey - ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + const waitingForSession = draftKey + ? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES) : NO_PENDING_MESSAGES useEffect(() => { - if (!pendingKey || pending.length === 0) { + if (!draftKey || !pendingKey || waitingForSession.length === 0) { return } + const movedIds = new Set(waitingForSession.map((item) => item.id)) + setPendingBySession((previous) => + mergeWaitingSessionPending(previous, pendingKey, waitingForSession) + ) + setPendingWaitingForSession((previous) => + removeWaitingSessionPending(previous, draftKey, movedIds) + ) + }, [draftKey, pendingKey, waitingForSession]) + + const sessionPending = pendingKey + ? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES) + : NO_PENDING_MESSAGES + const pending = combineMobileNativeChatPending(sessionPending, waitingForSession) + useEffect(() => { + if (!pendingKey) { + return + } + setImagePreviewsBySession((previous) => + migrateImagePreviewMessageIds(previous, pendingKey, messages) + ) + if (pending.length === 0) { + return + } + const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending) + const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId)) + if (landedImagePreviews.length > 0) { + setImagePreviewsBySession((previous) => + mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews) + ) + } setPendingBySession((previous) => { const current = previous[pendingKey] ?? [] const landedCounts = new Map() @@ -297,12 +306,20 @@ export function useMobileNativeChatDrafts(args: { // baseline tail — text echoes are excluded so an unrelated outstanding // text send cannot clear it. Ordinal-vs-count stays stable when the effect // re-runs on the shrunken list, and ignores paginated-in history. - const next = current.filter((item) => - item.text.trim() === '' + const next = current.filter((item) => { + if (landedImagePendingIds.has(item.id)) { + return false + } + // Image echoes hand their local URIs to the authoritative message above; + // never drop them through the text-only fallback before that handoff. + if (item.images?.length) { + return true + } + return item.text.trim() === '' ? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < - item.expectedOccurrence + item.expectedOccurrence : (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence - ) + }) if (next.length === current.length) { return previous } @@ -319,6 +336,9 @@ export function useMobileNativeChatDrafts(args: { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, pending, + imagePreviewsByMessageId: pendingKey + ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) + : NO_IMAGE_PREVIEWS, captureSendOrigin, readSeededLaunchDraft, readSeededLaunchDraftSeed, diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts index 13cb5d96c48..a57e1c7520c 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.test.ts @@ -11,13 +11,13 @@ import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-im // Fully stub the picker so the real expo/react-native chain never loads under // the vitest transform (react-native ships Flow syntax rolldown can't parse). vi.mock('./mobile-image-source-picker', () => ({ - pickMobileImage: vi.fn(), + pickMobileImages: vi.fn(), ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {} })) -import { pickMobileImage } from './mobile-image-source-picker' +import { pickMobileImages } from './mobile-image-source-picker' -const pick = vi.mocked(pickMobileImage) +const pick = vi.mocked(pickMobileImages) function ok(id: string, result: unknown): RpcSuccess { return { id, ok: true, result, _meta: { runtimeId: 'r' } } @@ -124,7 +124,7 @@ describe('useMobileNativeChatImageAttachments', () => { } it('adds an uploaded image as a chip without pasting to the terminal', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) mount( baseArgs({ @@ -145,7 +145,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -208,7 +208,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => { // A single Ctrl+U kills only the last line, so the draft's earlier lines // would survive the clear and ride along with the image as prompt body. - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -242,7 +242,7 @@ describe('useMobileNativeChatImageAttachments', () => { it('spends one budget across the image paste and the text body that follows', async () => { vi.useFakeTimers() try { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -280,7 +280,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -321,7 +321,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and does not submit when the image paste is rejected', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -345,7 +345,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('keeps the chips and reports failure when the paste transport throws', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) // No terminal.send responses queued: the clear write throws (dropped transport). const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') @@ -365,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const onSendError = vi.fn() @@ -387,7 +387,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('scopes chips to the tab that attached them', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')]) const baseSend = vi.fn().mockResolvedValue('accepted') const args = baseArgs({ client: client as unknown as RpcClient, baseSend }) @@ -426,7 +426,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let firstAttach: Promise | null = null await act(async () => { firstAttach = hook!.attachImage('library') @@ -438,7 +438,7 @@ describe('useMobileNativeChatImageAttachments', () => { expect(hook!.isAttaching).toBe(true) // A concurrent cancelled pick — its finally must leave the counter alone. - pick.mockResolvedValue(null) + pick.mockResolvedValue([]) await act(async () => { await hook!.attachImage('library') }) @@ -454,7 +454,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('clears only the chips that were sent, keeping one attached mid-send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), // first attach @@ -488,7 +488,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) expect(releaseSettle).not.toBeNull() - pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' }) + pick.mockResolvedValue([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) await act(async () => { await hook!.attachImage('library') }) @@ -511,7 +511,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('aborts the send when the active terminal changes during the settle window', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -560,7 +560,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -596,7 +596,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -634,7 +634,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('still heals after the session screen unmounts and remounts (#10228)', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -688,7 +688,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('retains the stale marker when a rejected healing clear blocks text-only send', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -730,7 +730,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('does not reroute text when the active terminal changes during a healing clear', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let releaseClear: ((response: RpcResponse) => void) | null = null const deferredClear = new Promise((resolve) => { releaseClear = resolve @@ -774,7 +774,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) it('defers the heal instead of burning a rejected clear while the lease is closed', async () => { - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) const client = makeClient([ methodNotFound('start'), ok('save', '/tmp/a.png'), @@ -818,8 +818,8 @@ describe('useMobileNativeChatImageAttachments', () => { it('heals rejected image submits independently across terminals', async () => { pick - .mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' }) - .mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' }) + .mockResolvedValueOnce([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) + .mockResolvedValueOnce([{ base64: 'BBBB', uri: 'file:///b.jpg' }]) const client = makeClient([ methodNotFound('start-a'), ok('save-a', '/tmp/a.png'), @@ -896,7 +896,7 @@ describe('useMobileNativeChatImageAttachments', () => { }) }) mount(args) - pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' }) + pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }]) let attach: Promise | null = null await act(async () => { attach = hook!.attachImage('library') diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts index 38f9e462e46..dbcb97df527 100644 --- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts +++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts @@ -5,11 +5,12 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { ImageLibraryPermissionError, - pickMobileImage, + pickMobileImages, type MobileImageSource } from './mobile-image-source-picker' import { - uploadMobileNativeChatImage, + appendPendingNativeChatImages, + uploadMobileNativeChatImages, type PendingNativeChatImage } from './mobile-native-chat-image-attachment' import { @@ -82,10 +83,6 @@ export type MobileNativeChatImageAttachments = { readonly sendNativeChat: (text: string) => Promise } -function getErrorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error) -} - const NO_ATTACHMENTS: PendingNativeChatImage[] = [] function withScopeAttachments( @@ -146,48 +143,53 @@ export function useMobileNativeChatImageAttachments({ // pick or pre-upload error never ran `onUploadStart`, so decrementing the // shared counter would clear a concurrent upload's in-flight flag early. let started = false + const uploadedImages: Omit[] = [] + let uploadError: unknown = null try { - const uploaded = await uploadMobileNativeChatImage(source, { + await uploadMobileNativeChatImages(source, { client, getConnectionId: getActiveWorktreeConnectionId, - pickImage: pickMobileImage, + pickImages: pickMobileImages, + onImageUploaded: (image) => uploadedImages.push(image), onUploadStart: () => { started = true attachingCount.current += 1 setIsAttaching(true) } }) - // Cancelled picker: no error, no toast. - if (!uploaded) { - return - } - idCounter.current += 1 - const chip = { id: `img-${idCounter.current}`, ...uploaded } - setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] })) - onAttachSuccess?.() } catch (error) { + uploadError = error + } finally { + if (started) { + attachingCount.current -= 1 + if (attachingCount.current === 0) { + setIsAttaching(false) + } + } + } + if (uploadedImages.length > 0) { + setAttachmentsByScope((prev) => ({ + ...prev, + [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter) + })) + onAttachSuccess?.() + } + if (uploadError !== null) { + const message = uploadError instanceof Error ? uploadError.message : String(uploadError) onError?.() if (connStateRef.current !== 'connected') { showToast('Attach failed (disconnected)', 1500) return } - if (error instanceof ImageLibraryPermissionError) { + if (uploadError instanceof ImageLibraryPermissionError) { showToast('Photo permission denied', 1500) return } - if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { + if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) { showToast('Image too large to attach', 1500) return } showToast('Attach failed', 1500) - } finally { - if (started) { - attachingCount.current -= 1 - if (attachingCount.current <= 0) { - attachingCount.current = 0 - setIsAttaching(false) - } - } } }, [ diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts new file mode 100644 index 00000000000..bb76d2df75b --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts @@ -0,0 +1,233 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { RpcClient } from '../transport/rpc-client' +import type { RpcResponse } from '../transport/types' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' + +function success(result: unknown): RpcResponse { + return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } } +} + +function clientWith(sendRequest: RpcClient['sendRequest']): Pick { + return { sendRequest } +} + +function options(sendRequest: RpcClient['sendRequest']) { + return { + client: clientWith(sendRequest), + worktreeId: 'worktree-1', + relativePath: 'src/target.ts', + tabMode: 'diff' as const, + staged: true + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('revealMobileSourceControlSessionDiff', () => { + it('activates the requested file when its diff tab is already open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + activeTabId: 'agent-tab', + tabs: [ + { id: 'agent-tab', type: 'terminal' }, + { + id: 'other-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/other.ts' + }, + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', { + worktree: 'id:worktree-1', + tabId: 'target-diff', + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + }) + + it('selects the staged diff when both versions of a file are open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + }, + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe( + 'revealed' + ) + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('waits for the requested diff source instead of activating the other one', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'unstaged-diff', + type: 'file', + mode: 'diff', + diffSource: 'unstaged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'staged-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'staged-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'staged-diff' }) + ) + }) + + it('activates a legacy edit tab when opening a diff falls back to files.open', async () => { + const sendRequest = vi + .fn() + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'stale-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + }, + { + id: 'target-edit', + type: 'file', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-edit' })) + + await expect( + revealMobileSourceControlSessionDiff({ ...options(sendRequest), tabMode: 'edit' }) + ).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenLastCalledWith( + 'session.tabs.activate', + expect.objectContaining({ tabId: 'target-edit' }) + ) + }) + + it('retries until the opened diff appears in the session snapshot', async () => { + vi.useFakeTimers() + const sendRequest = vi + .fn() + .mockResolvedValueOnce(success({ tabs: [{ id: 'agent-tab', type: 'terminal' }] })) + .mockResolvedValueOnce( + success({ + tabs: [ + { + id: 'target-diff', + type: 'file', + mode: 'diff', + diffSource: 'staged', + relativePath: 'src/target.ts' + } + ] + }) + ) + .mockResolvedValueOnce(success({ activeTabId: 'target-diff' })) + + const reveal = revealMobileSourceControlSessionDiff(options(sendRequest)) + await vi.advanceTimersByTimeAsync(300) + + await expect(reveal).resolves.toBe('revealed') + expect(sendRequest).toHaveBeenCalledTimes(3) + }) + + it('delegates to the mounted session when the dock callback is available', async () => { + const sendRequest = vi.fn() + const onOpenedFileDiff = vi.fn() + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + onOpenedFileDiff + }) + ).resolves.toBe('revealed') + + expect(onOpenedFileDiff).toHaveBeenCalledWith('src/target.ts') + expect(sendRequest).not.toHaveBeenCalled() + }) + + it('cancels route-owned polling after the source-control screen unmounts', async () => { + let current = true + const sendRequest = vi.fn().mockImplementation(async () => { + current = false + return success({ tabs: [] }) + }) + + await expect( + revealMobileSourceControlSessionDiff({ + ...options(sendRequest), + isCurrent: () => current + }) + ).resolves.toBe('cancelled') + expect(sendRequest).toHaveBeenCalledOnce() + }) +}) diff --git a/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts new file mode 100644 index 00000000000..9391c0c2961 --- /dev/null +++ b/mobile/src/source-control/reveal-mobile-source-control-session-diff.ts @@ -0,0 +1,143 @@ +import type { RpcClient } from '../transport/rpc-client' +import { activateMobileSessionTab } from '../session/mobile-session-tab-activation' + +type ActivationClient = Pick + +type SessionFileTabCandidate = { + id: string + type: string + mode?: unknown + relativePath?: unknown + diffSource?: unknown +} + +type Options = { + client: ActivationClient + worktreeId: string + relativePath: string + tabMode: 'diff' | 'edit' + staged: boolean + onOpenedFileDiff?: (relativePath: string) => void + isCurrent?: () => boolean +} + +export type MobileSourceControlSessionDiffRevealResult = 'revealed' | 'cancelled' | 'timeout' + +const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const + +export async function revealMobileSourceControlSessionDiff( + options: Options +): Promise { + if (options.onOpenedFileDiff) { + options.onOpenedFileDiff(options.relativePath) + return 'revealed' + } + + for (const delayMs of TAB_POLL_DELAYS_MS) { + await waitForDelay(delayMs) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + + const tab = await findOpenedSessionFileTab(options) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (!tab) { + continue + } + + const activated = await activateSessionFileTab(options, tab.id) + if (options.isCurrent?.() === false) { + return 'cancelled' + } + if (activated) { + return 'revealed' + } + } + + return 'timeout' +} + +async function findOpenedSessionFileTab(options: Options): Promise { + try { + const response = await options.client.sendRequest('session.tabs.list', { + worktree: `id:${options.worktreeId}` + }) + if (!response.ok) { + return null + } + const snapshot = readTabSnapshot(response.result) + if (!snapshot) { + return null + } + + const matches = snapshot.tabs.filter( + (tab) => + tab.type !== 'browser' && + tab.type !== 'terminal' && + matchesTabMode(tab.mode, options.tabMode) && + tab.relativePath === options.relativePath + ) + if (options.tabMode === 'edit') { + return matches[0] ?? null + } + const source = options.staged ? 'staged' : 'unstaged' + return ( + matches.find((tab) => tab.diffSource === source) ?? + matches.find((tab) => tab.diffSource == null) ?? + null + ) + } catch { + return null + } +} + +function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean { + return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null +} + +async function activateSessionFileTab(options: Options, tabId: string): Promise { + try { + const response = await activateMobileSessionTab(options.client, { + worktree: `id:${options.worktreeId}`, + tabId, + notifyClients: false, + navigation: 'caller', + intent: 'user' + }) + return response.ok && readActiveTabId(response.result) === tabId + } catch { + return false + } +} + +function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null { + if ( + !isRecord(value) || + !Array.isArray(value.tabs) || + !value.tabs.every(isSessionFileTabCandidate) + ) { + return null + } + return { tabs: value.tabs } +} + +function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate { + return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string' +} + +function readActiveTabId(value: unknown): string | null { + return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +async function waitForDelay(delayMs: number): Promise { + if (delayMs === 0) { + return + } + await new Promise((resolve) => setTimeout(resolve, delayMs)) +} diff --git a/mobile/src/source-control/use-mobile-source-control-openers.ts b/mobile/src/source-control/use-mobile-source-control-openers.ts index bf0eff63cf6..f6c44825715 100644 --- a/mobile/src/source-control/use-mobile-source-control-openers.ts +++ b/mobile/src/source-control/use-mobile-source-control-openers.ts @@ -18,6 +18,7 @@ import { type MobileGitStatusEntry } from './mobile-git-status' import { buildMobileReviewFileRoute } from './mobile-review-route' +import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff' import type { GitDiffTextResult, MobileBranchCompareState, @@ -115,11 +116,13 @@ export function useMobileSourceControlOpeners(params: Params) { relativePath: entry.path, staged: entry.area === 'staged' }) + let openedTabMode: 'diff' | 'edit' = 'diff' if (!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)) { response = await client.sendRequest('files.open', { worktree: `id:${worktreeId}`, relativePath: entry.path }) + openedTabMode = 'edit' } if (!response.ok) { throw new Error(response.error?.message || 'Unable to open diff') @@ -127,8 +130,22 @@ export function useMobileSourceControlOpeners(params: Params) { if (!mountedRef.current) { return } + const revealResult = await revealMobileSourceControlSessionDiff({ + client, + worktreeId, + relativePath: entry.path, + tabMode: openedTabMode, + staged: entry.area === 'staged', + onOpenedFileDiff, + isCurrent: () => mountedRef.current && openingPathRef.current === entry.path + }) + if (revealResult === 'cancelled') { + return + } + if (revealResult === 'timeout') { + throw new Error("The file opened, but its tab isn't ready yet. Try again.") + } triggerSelection() - onOpenedFileDiff?.(entry.path) // Why: when launched from the session screen, opening a file dismisses // this surface back to the session. In embedded mode there is nothing // to pop (the panel docks beside the terminal), so close the dock diff --git a/mobile/src/tasks/mobile-task-navigation.test.ts b/mobile/src/tasks/mobile-task-navigation.test.ts index 5bc519578ba..bacc24be5b8 100644 --- a/mobile/src/tasks/mobile-task-navigation.test.ts +++ b/mobile/src/tasks/mobile-task-navigation.test.ts @@ -32,8 +32,9 @@ describe('mobile task navigation', () => { it('waits for the expected host commit before navigating to Tasks', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push }, 'host/1') + navigateToMobileTasks(harness.navigation, { push, replace }, 'host/1') expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan( push.mock.invocationCallOrder[0]! @@ -46,38 +47,25 @@ describe('mobile task navigation', () => { routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() - - harness.setState({ - index: 1, - routes: [ - { name: 'index' }, - { - name: 'h', - state: { - key: '/h', - index: 0, - routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }] - } - } - ] - }) - expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan( - harness.navigation.dispatch.mock.invocationCallOrder[0]! + replace.mock.invocationCallOrder[0]! ) - expect(harness.navigation.dispatch).toHaveBeenCalledWith({ - type: 'REPLACE', - target: '/h', - source: 'host-index', - payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } } + expect(replace).toHaveBeenCalledWith({ + pathname: '/h/[hostId]/tasks', + params: { hostId: 'host/1' } }) }) it('ignores unrelated state events and preserves the provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear') + navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1', + 'linear' + ) harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] }) expect(harness.navigation.dispatch).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] }) @@ -108,30 +96,37 @@ describe('mobile task navigation', () => { it('cleanup prevents a stale navigation from replacing', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') + const replace = vi.fn() + const controller = navigateToMobileTasks( + harness.navigation, + { push: vi.fn(), replace }, + 'host-1' + ) controller.cancel() harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('reuses a pending host push and applies the latest provider', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() + const router = { push, replace: vi.fn() } const first = coordinateMobileTasksNavigation( null, harness.navigation, - { push }, + router, 'host-1', 'github' ) const second = coordinateMobileTasksNavigation( first, harness.navigation, - { push }, + router, 'host-1', 'linear' ) @@ -161,11 +156,12 @@ describe('mobile task navigation', () => { ) }) - it('keeps waiting through host setup and cleans up when navigation leaves', () => { + it('keeps waiting through host setup without params and cleans up when navigation leaves', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) + const replace = vi.fn() - navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1') - harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] }) + navigateToMobileTasks(harness.navigation, { push: vi.fn(), replace }, 'host-1') + harness.setState({ index: 0, routes: [{ name: 'h' }] }) expect(harness.unsubscribeState).not.toHaveBeenCalled() harness.setState({ index: 0, routes: [{ name: 'index' }] }) harness.setState({ @@ -184,6 +180,7 @@ describe('mobile task navigation', () => { expect(harness.unsubscribeState).toHaveBeenCalledOnce() expect(harness.navigation.dispatch).not.toHaveBeenCalled() + expect(replace).not.toHaveBeenCalled() }) it('unsubscribes when mounting the host throws synchronously', () => { @@ -196,7 +193,8 @@ describe('mobile task navigation', () => { { push: () => { throw error - } + }, + replace: vi.fn() }, 'host-1' ) diff --git a/mobile/src/transport/host-edit-navigation.test.ts b/mobile/src/transport/host-edit-navigation.test.ts index cd0de461288..db463413a46 100644 --- a/mobile/src/transport/host-edit-navigation.test.ts +++ b/mobile/src/transport/host-edit-navigation.test.ts @@ -52,7 +52,7 @@ describe('mobile host edit navigation', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) const push = vi.fn() - navigateToMobileHostEdit(harness.navigation, { push }, 'host/1') + navigateToMobileHostEdit(harness.navigation, { push, replace: vi.fn() }, 'host/1') expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -75,7 +75,7 @@ describe('mobile host edit navigation', () => { it('does not replace an unrelated host route', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') harness.setState(committedHostState('host-2')) expect(harness.navigation.dispatch).not.toHaveBeenCalled() @@ -83,7 +83,7 @@ describe('mobile host edit navigation', () => { it('disposes itself when navigation leaves the host flow without cancel()', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1') // Enter the host flow for a different host, then leave it entirely. harness.setState(committedHostState('host-2')) @@ -97,7 +97,11 @@ describe('mobile host edit navigation', () => { it('cancels a pending replacement when navigation leaves the host flow', () => { const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] }) - const controller = navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1') + const controller = navigateToMobileHostEdit( + harness.navigation, + { push: vi.fn(), replace: vi.fn() }, + 'host-1' + ) harness.setState(committedHostState('host-2')) controller.cancel() diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts index 41d53189880..96d71b37680 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.test.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.test.ts @@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => { expect(saved.metadata.authorizationMode).toBe('authenticated-direct') expect(deps.updateJournal).toHaveBeenCalledTimes(2) }) + // Why: a journal stranded by a relay outage used to block every later pairing + // with "recovery pending" forever, because recovery only ever deferred. + it('abandons a journal once its invite expired and no credential can reconcile', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned') + expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId) + }) + + it('keeps a just-expired journal so a brief outage cannot discard it', async () => { + const saved = journal() + const unreachable = client(async () => { + throw new Error('relay unreachable') + }) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }), + now: () => saved.metadata.relay.inviteExpiresAt + 1 + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) + + // Why: a committed install whose local persistence failed is the one case the + // journal must survive — it is the only record left to retry the write from. + it('keeps a journal when the server committed but the local write failed', async () => { + const saved = journal() + const directInstalled = installed(saved, 'authenticated-direct') + const committed = client(async () => + response(endpoints(saved, { state: 'committed', result: directInstalled })) + ) + const deps = { + ...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }), + now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1, + writeCredentialBundle: vi.fn(async () => { + throw new Error('keychain unavailable') + }) + } + + await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred') + expect(deps.clearJournal).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/transport/mobile-relay-pairing-recovery.ts b/mobile/src/transport/mobile-relay-pairing-recovery.ts index 0d5ba7e5e07..26dc08620de 100644 --- a/mobile/src/transport/mobile-relay-pairing-recovery.ts +++ b/mobile/src/transport/mobile-relay-pairing-recovery.ts @@ -27,7 +27,7 @@ import { import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate' import type { HostProfile, RpcResponse } from './types' -export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' +export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned' type RecoveryDependencies = { loadJournal: typeof loadMobileRelayPairingJournal @@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = { platform: Platform.OS } +// One full invite lifetime past expiry, so a momentary outage never discards a +// journal that a later launch could still reconcile. +const ABANDON_GRACE_MS = 10 * 60 * 1000 + let recoveryPromise: Promise | null = null export function recoverMobileRelayPairing( @@ -96,6 +100,10 @@ async function runRecovery( } const credentials = recoveryCredentials(journal, bundle, dependencies.now()) + // Why: publishCommitted runs inside the catch below, so a failed local write + // of an authoritatively committed install must not look like "nothing to + // reconcile" — that journal is the only record left to retry the write from. + let observedCommitted = false for (const credential of credentials) { let client: PairingCandidateClient | null = null try { @@ -113,6 +121,7 @@ async function runRecovery( }) const endpoints = await getRecoveryStatus(client, journal, credential.kind) if (endpoints.installStatus?.state === 'committed') { + observedCommitted = true await publishCommitted(journal, endpoints, dependencies) return 'recovered' } @@ -128,6 +137,7 @@ async function runRecovery( ) const reconciled = await getRecoveryStatus(client, journal, 'invite') assertCommitted(reconciled, installed) + observedCommitted = true await publishCommitted(journal, reconciled, dependencies) return 'recovered' } @@ -138,6 +148,19 @@ async function runRecovery( client?.close() } } + // Why: past invite expiry no credential can still establish what happened, so + // retaining the journal cannot reconcile anything — it only fails every later + // pairing with "recovery pending" forever. Re-pairing mints a fresh device and + // any uncommitted server-side install expires on its own. The extra invite + // lifetime of slack keeps a brief relay outage from discarding a journal whose + // resume credential would have reconciled it on the next launch. + if ( + !observedCommitted && + journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now() + ) { + await dependencies.clearJournal(journal.metadata.journalId).catch(() => {}) + return 'abandoned' + } return 'deferred' } diff --git a/package.json b/package.json index e8ec89daae0..cbc7862f8fd 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,7 @@ "jsonc-parser": "^3.3.1", "node-pty": "^1.1.0", "posthog-node": "^5.33.3", + "psl": "1.15.0", "qrcode": "^1.5.4", "react-i18next": "^17.0.8", "serve-sim": "^0.1.40", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c4d53a979e1..c361a5382aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,6 +68,9 @@ importers: posthog-node: specifier: ^5.33.3 version: 5.33.3 + psl: + specifier: 1.15.0 + version: 1.15.0 qrcode: specifier: ^1.5.4 version: 1.5.4 @@ -5793,9 +5796,16 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + pvtsutils@1.3.6: resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} @@ -12448,11 +12458,17 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + punycode@2.3.1: {} + pvtsutils@1.3.6: dependencies: tslib: 2.8.1 diff --git a/src/main/agent-hooks/installer-utils.ts b/src/main/agent-hooks/installer-utils.ts index e0688b44345..a6d5d61fd41 100644 --- a/src/main/agent-hooks/installer-utils.ts +++ b/src/main/agent-hooks/installer-utils.ts @@ -161,7 +161,14 @@ export function wrapWindowsGitBashHookCommand(scriptPath: string): string { : wrapWindowsHookCommand(scriptPath) } -export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string { +/** + * Extra form lines inserted before the final `payload@-` line (each should end with ` ^`). + * Used by Grok to attach `grokHome` without fragile string replace on the shared template. + */ +export function buildWindowsAgentHookPostCommand( + source: AgentHookSource, + extraFormLines: readonly string[] = [] +): string { // Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path. // Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads. return [ @@ -175,6 +182,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin ' --data-urlencode "worktreeId=%ORCA_WORKTREE_ID%" ^', ' --data-urlencode "env=%ORCA_AGENT_HOOK_ENV%" ^', ' --data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%" ^', + ...extraFormLines, ' --data-urlencode "payload@-" >nul 2>nul' ].join('\r\n') } diff --git a/src/main/browser/browser-cookie-import-policy.test.ts b/src/main/browser/browser-cookie-import-policy.test.ts new file mode 100644 index 00000000000..31ca329bf8f --- /dev/null +++ b/src/main/browser/browser-cookie-import-policy.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Cookie } from 'electron' +import { + isGoogleSourceBoundCookie, + normalizeCookieDomain, + replaceCookiesForImportedDomains +} from './browser-cookie-import-policy' + +function cookie(domain: string, name: string, path = '/', secure = true): Cookie { + return { + domain, + name, + path, + secure, + sameSite: 'unspecified', + value: 'secret' + } +} + +describe('isGoogleSourceBoundCookie', () => { + it('matches the allowlisted names only on google.com and its subdomains', () => { + expect(isGoogleSourceBoundCookie('SIDCC', '.google.com')).toBe(true) + expect(isGoogleSourceBoundCookie('AEC', 'accounts.google.com')).toBe(true) + expect(isGoogleSourceBoundCookie('__Secure-STRP', '.accounts.google.com')).toBe(true) + expect(isGoogleSourceBoundCookie('SIDCC', '.notgoogle.com')).toBe(false) + expect(isGoogleSourceBoundCookie('SIDCC', '.google.com.evil.example')).toBe(false) + expect(isGoogleSourceBoundCookie('SID', '.google.com')).toBe(false) + }) + + it('normalizes leading dots, case, and international domains consistently', () => { + expect(normalizeCookieDomain('..Accounts.Google.Com')).toBe('accounts.google.com') + expect(normalizeCookieDomain('münich.example')).toBe('xn--mnich-kva.example') + expect(normalizeCookieDomain('')).toBeNull() + }) + + it('rejects URL syntax that could normalize an invalid cookie scope to another domain', () => { + expect(normalizeCookieDomain('example.com/path')).toBeNull() + expect(normalizeCookieDomain('user@example.com')).toBeNull() + expect(normalizeCookieDomain('example.com:443')).toBeNull() + expect(normalizeCookieDomain('%65xample.com')).toBeNull() + expect(isGoogleSourceBoundCookie('SIDCC', 'user@google.com')).toBe(false) + }) +}) + +describe('replaceCookiesForImportedDomains', () => { + it('removes parent, exact, and child-domain cookies while preserving unrelated sites', async () => { + const existing = [ + cookie('.google.com', 'parent'), + { ...cookie('google.com', 'host-only-parent'), hostOnly: true }, + cookie('.accounts.google.com', 'exact', '/signin'), + cookie('.child.accounts.google.com', 'child', '/nested', false), + cookie('.google.com.evil.example', 'suffix-confusion'), + cookie('.example.com', 'unrelated') + ] + const get = vi.fn().mockResolvedValue(existing) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const removed = await replaceCookiesForImportedDomains({ get, remove, set }, [ + 'accounts.google.com' + ]) + + expect(removed).toHaveLength(3) + expect(get).toHaveBeenCalledWith({}) + expect(remove.mock.calls).toEqual([ + ['https://google.com/', 'parent'], + ['https://accounts.google.com/signin', 'exact'], + ['http://child.accounts.google.com/nested', 'child'] + ]) + expect(set).not.toHaveBeenCalled() + }) + + it('does not replace a private-suffix host cookie for a tenant import', async () => { + const get = vi + .fn() + .mockResolvedValue([ + { ...cookie('github.io', 'host-only-suffix'), hostOnly: true }, + cookie('.user.github.io', 'tenant') + ]) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['user.github.io']) + + expect(removed.map(({ name }) => name)).toEqual(['tenant']) + expect(remove).toHaveBeenCalledWith('https://user.github.io/', 'tenant') + }) + + it('does not read or mutate the store when no valid domain scope exists', async () => { + const get = vi.fn() + const remove = vi.fn() + const set = vi.fn() + + await expect( + replaceCookiesForImportedDomains({ get, remove, set }, [ + '', + '...', + 'com', + 'co.uk', + 'github.io' + ]) + ).resolves.toEqual([]) + expect(get).not.toHaveBeenCalled() + expect(remove).not.toHaveBeenCalled() + expect(set).not.toHaveBeenCalled() + }) + + it('keeps single-label intranet scopes from selecting descendant hosts', async () => { + const get = vi + .fn() + .mockResolvedValue([cookie('local', 'exact'), cookie('.service.local', 'descendant')]) + const remove = vi.fn().mockResolvedValue(undefined) + const set = vi.fn().mockResolvedValue(undefined) + + const removed = await replaceCookiesForImportedDomains({ get, remove, set }, ['local']) + + expect(removed.map(({ name }) => name)).toEqual(['exact']) + expect(remove).toHaveBeenCalledOnce() + expect(remove).toHaveBeenCalledWith('https://local/', 'exact') + }) + + it('restores cookies removed before a later removal fails', async () => { + const existing = [ + cookie('.example.com', 'first', '/one'), + cookie('.example.com', 'second', '/two') + ] + const get = vi.fn().mockResolvedValue(existing) + const remove = vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie store unavailable')) + const set = vi.fn().mockResolvedValue(undefined) + + await expect( + replaceCookiesForImportedDomains({ get, remove, set }, ['example.com']) + ).rejects.toThrow('cookie store unavailable') + expect(set).toHaveBeenCalledOnce() + expect(set).toHaveBeenCalledWith({ + url: 'https://example.com/one', + name: 'first', + value: 'secret', + domain: '.example.com', + path: '/one', + secure: true, + httpOnly: undefined, + sameSite: 'unspecified' + }) + }) +}) diff --git a/src/main/browser/browser-cookie-import-policy.ts b/src/main/browser/browser-cookie-import-policy.ts new file mode 100644 index 00000000000..216542f08ea --- /dev/null +++ b/src/main/browser/browser-cookie-import-policy.ts @@ -0,0 +1,201 @@ +import type { Cookie, Cookies } from 'electron' +import { parse as parseDomain } from 'psl' + +const GOOGLE_SOURCE_BOUND_COOKIE_NAMES = new Set([ + 'SIDCC', + '__Secure-1PSIDCC', + '__Secure-3PSIDCC', + '__Secure-STRP', + 'AEC' +]) + +export type CookieImportMode = 'merge' | 'replace-imported-domains' + +export function normalizeCookieDomain(domain: string): string | null { + const candidate = domain.trim().replace(/^\.+/, '') + const isBracketedIpv6 = candidate.startsWith('[') && candidate.endsWith(']') + if (!candidate || /[/\\@?#%]/.test(candidate) || (!isBracketedIpv6 && candidate.includes(':'))) { + return null + } + try { + const parsed = new URL(`https://${candidate}/`) + const normalized = parsed.hostname.toLowerCase() + if ( + parsed.username || + parsed.password || + parsed.port || + parsed.pathname !== '/' || + parsed.search || + parsed.hash || + normalized.endsWith('.') || + normalized.includes('..') + ) { + return null + } + return normalized + } catch { + return null + } +} + +export function normalizeCookieImportDomain(domain: string): string | null { + const normalized = normalizeCookieDomain(domain) + if (!normalized) { + return null + } + const parsed = parseDomain(normalized) + if ('error' in parsed) { + return normalized.startsWith('[') && normalized.endsWith(']') ? normalized : null + } + if (parsed.domain === null && parsed.listed) { + return null + } + return normalized +} + +export function isGoogleSourceBoundCookie(name: string, domain: string): boolean { + if (!GOOGLE_SOURCE_BOUND_COOKIE_NAMES.has(name)) { + return false + } + const normalized = normalizeCookieDomain(domain) + return normalized === 'google.com' || normalized?.endsWith('.google.com') === true +} + +function domainSuffixes(domain: string): string[] { + const labels = domain.split('.') + return labels.map((_, index) => labels.slice(index).join('.')) +} + +function importDomainAncestors(domain: string): string[] { + const parsed = parseDomain(domain) + const boundary = 'error' in parsed ? domain : (parsed.domain ?? domain) + const ancestors: string[] = [] + for (const suffix of domainSuffixes(domain)) { + ancestors.push(suffix) + if (suffix === boundary) { + break + } + } + return ancestors +} + +function importedDomainScopes(domains: readonly string[]): { + exact: Set + ancestors: Set + descendantRoots: Set +} { + const exact = new Set() + const ancestors = new Set() + const descendantRoots = new Set() + const seen = new Set() + for (const domain of domains) { + const candidate = normalizeCookieDomain(domain) + if (!candidate || seen.has(candidate)) { + continue + } + seen.add(candidate) + const normalized = normalizeCookieImportDomain(candidate) + if (!normalized || exact.has(normalized)) { + continue + } + exact.add(normalized) + if (normalized.includes('.')) { + descendantRoots.add(normalized) + } + for (const suffix of importDomainAncestors(normalized)) { + ancestors.add(suffix) + } + } + return { exact, ancestors, descendantRoots } +} + +function overlapsImportedDomain( + cookie: Cookie, + domain: string, + scopes: ReturnType +): boolean { + if (scopes.exact.has(domain)) { + return true + } + if (cookie.hostOnly !== true && scopes.ancestors.has(domain)) { + return true + } + return domainSuffixes(domain).some((suffix) => scopes.descendantRoots.has(suffix)) +} + +function cookieRemovalUrl(cookie: Cookie, domain: string): string | null { + try { + const url = new URL(`${cookie.secure ? 'https' : 'http'}://${domain}/`) + url.pathname = cookie.path?.startsWith('/') ? cookie.path : '/' + return url.toString() + } catch { + return null + } +} + +export async function restoreImportedDomainCookies( + store: Pick, + cookies: readonly Cookie[] +): Promise { + const failures: unknown[] = [] + for (const cookie of cookies) { + try { + const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + const url = domain ? cookieRemovalUrl(cookie, domain) : null + if (!url) { + continue + } + await store.set({ + url, + name: cookie.name, + value: cookie.value, + ...(cookie.hostOnly ? {} : { domain: cookie.domain }), + ...(cookie.path ? { path: cookie.path } : {}), + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + ...(cookie.expirationDate ? { expirationDate: cookie.expirationDate } : {}) + }) + } catch (err) { + failures.push(err) + } + } + if (failures.length > 0) { + throw new AggregateError(failures, 'Could not restore replaced cookies') + } +} + +export async function replaceCookiesForImportedDomains( + store: Pick, + importedDomains: readonly string[] +): Promise { + const scopes = importedDomainScopes(importedDomains) + if (scopes.exact.size === 0) { + return [] + } + + const existingCookies = await store.get({}) + const removedCookies: Cookie[] = [] + for (const cookie of existingCookies) { + const domain = cookie.domain ? normalizeCookieDomain(cookie.domain) : null + if (!domain || !overlapsImportedDomain(cookie, domain, scopes)) { + continue + } + const url = cookieRemovalUrl(cookie, domain) + if (!url) { + continue + } + try { + await store.remove(url, cookie.name) + removedCookies.push(cookie) + } catch (err) { + try { + await restoreImportedDomainCookies(store, removedCookies) + } catch (restoreError) { + throw new AggregateError([err, restoreError], 'Cookie replacement and rollback failed') + } + throw err + } + } + return removedCookies +} diff --git a/src/main/browser/browser-cookie-import-replacement.test.ts b/src/main/browser/browser-cookie-import-replacement.test.ts new file mode 100644 index 00000000000..c3e330f5a67 --- /dev/null +++ b/src/main/browser/browser-cookie-import-replacement.test.ts @@ -0,0 +1,278 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { + appGetPathMock, + clearPendingCookieImportMock, + execFileSyncMock, + sessionFromPartitionMock, + setPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + clearPendingCookieImportMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + setPendingCookieImportMock: vi.fn() +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock, + persistUserAgent: vi.fn() + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + dialog: { showOpenDialog: vi.fn() }, + session: { fromPartition: sessionFromPartitionMock } +})) + +import { importCookiesFromBrowser, importCookiesFromFile } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +describe('validated cookie replacement', () => { + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-replacement-test-')) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReset().mockReturnValue({ + cookies: { get: cookiesGetMock, remove: cookiesRemoveMock, set: cookiesSetMock } + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function writeCookies(cookies: unknown[]): string { + const filePath = join(tmpDir, 'cookies.json') + writeFileSync(filePath, JSON.stringify(cookies)) + return filePath + } + + it('filters Google source-bound cookies before replacing imported domain scopes', async () => { + cookiesGetMock.mockResolvedValue([ + cookie('.google.com', 'old-google'), + cookie('.accounts.google.com', 'old-accounts', '/signin'), + cookie('.unrelated.com', 'keep'), + cookie('.google.com.evil.example', 'keep-suffix-confusion') + ]) + const filePath = writeCookies([ + { domain: '.google.com', name: 'SIDCC', value: 'source-bound', secure: true }, + { domain: '.google.com', name: 'SAPISID', value: 'google-session', secure: true }, + { domain: '.example.com', name: 'SIDCC', value: 'not-google', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 3, + importedCookies: 2, + skippedCookies: 1, + domains: ['example.com', 'google.com'] + }) + expect(cookiesRemoveMock.mock.calls).toEqual([ + ['https://google.com/', 'old-google'], + ['https://accounts.google.com/signin', 'old-accounts'] + ]) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'SIDCC']) + expect(Math.max(...cookiesRemoveMock.mock.invocationCallOrder)).toBeLessThan( + Math.min(...cookiesSetMock.mock.invocationCallOrder) + ) + }) + + it('does not touch the store when every valid entry is source-bound', async () => { + const filePath = writeCookies([ + { domain: '.google.com', name: 'AEC', value: 'source-bound', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok && result.summary).toEqual({ + totalCookies: 1, + importedCookies: 0, + skippedCookies: 1, + domains: [] + }) + expect(cookiesGetMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('rejects URL-shaped domains before replacement can clear their normalized scope', async () => { + const filePath = writeCookies([ + { domain: 'example.com/path', name: 'session', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result).toEqual({ + ok: false, + reason: 'No valid cookies found. 1 entries were skipped due to missing or invalid fields.' + }) + expect(cookiesGetMock).not.toHaveBeenCalled() + expect(cookiesRemoveMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + }) + + it('restores the previous snapshot when an incoming cookie is rejected', async () => { + cookiesGetMock.mockResolvedValue([cookie('.example.com', 'existing')]) + cookiesSetMock + .mockResolvedValue(undefined) + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error('cookie rejected')) + const filePath = writeCookies([ + { domain: '.example.com', name: 'first', value: 'new', secure: true }, + { domain: '.example.com', name: 'second', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok).toBe(false) + expect(cookiesRemoveMock.mock.calls).toEqual([ + ['https://example.com/', 'existing'], + ['https://example.com/', 'first'] + ]) + expect(cookiesSetMock).toHaveBeenLastCalledWith({ + url: 'https://example.com/', + name: 'existing', + value: 'old', + domain: '.example.com', + path: '/', + secure: true, + httpOnly: undefined, + sameSite: 'unspecified' + }) + }) + + it('fails closed when existing cookies cannot be replaced', async () => { + cookiesGetMock.mockRejectedValue(new Error('cookie store unavailable')) + const filePath = writeCookies([ + { domain: '.example.com', name: 'session', value: 'new', secure: true } + ]) + + const result = await importCookiesFromFile(filePath, 'persist:test') + + expect(result.ok).toBe(false) + expect(cookiesSetMock).not.toHaveBeenCalled() + }) +}) + +describe('native Chromium integrity-cookie accounting', () => { + let clearStorageDataMock: ReturnType + let cookiesSetMock: ReturnType + let tmpDir: string + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-accounting-test-')) + appGetPathMock.mockReset().mockReturnValue(join(tmpDir, 'userData')) + execFileSyncMock.mockReset().mockImplementation(() => { + throw new Error('OS browser version lookup unavailable') + }) + clearPendingCookieImportMock.mockClear() + setPendingCookieImportMock.mockClear() + clearStorageDataMock = vi.fn().mockResolvedValue(undefined) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + sessionFromPartitionMock.mockReset().mockReturnValue({ + cookies: { + flushStore: vi.fn().mockResolvedValue(undefined), + remove: vi.fn().mockResolvedValue(undefined), + set: cookiesSetMock + }, + clearStorageData: clearStorageDataMock + }) + }) + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }) + }) + + it('includes domain-scoped integrity cookies in skippedCookies', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.google.com', name: 'AEC', value: 'source-bound' }, + { domain: '.google.com', name: 'SAPISID', value: 'google-session' }, + { domain: '.example.com', name: 'AEC', value: 'not-google' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, []).close() + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + expect(result.ok && result.summary).toMatchObject({ + totalCookies: 3, + importedCookies: 2, + skippedCookies: 1, + domains: ['example.com', 'google.com'] + }) + expect(cookiesSetMock.mock.calls.map(([details]) => details.name)).toEqual(['SAPISID', 'AEC']) + } finally { + platformSpy.mockRestore() + } + }) + + it('preserves the destination when every source cookie is filtered or invalid', async () => { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + const targetCookiesPath = join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { domain: '.google.com', name: 'AEC', value: 'source-bound' }, + { domain: '.accounts.google.com', name: 'SIDCC', value: 'source-bound' }, + { domain: 'example.com/path', name: 'invalid-domain', value: 'invalid' } + ]).close() + createChromiumCookieTestDatabase(targetCookiesPath, [ + { domain: '.example.com', name: 'existing', value: 'keep' } + ]).close() + const platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin') + + try { + const result = await importCookiesFromBrowser( + chromeBrowser(sourceCookiesPath), + 'persist:test' + ) + + expect(result.ok && result.summary).toEqual({ + totalCookies: 3, + importedCookies: 0, + skippedCookies: 3, + domains: [] + }) + expect(clearStorageDataMock).not.toHaveBeenCalled() + expect(cookiesSetMock).not.toHaveBeenCalled() + expect(setPendingCookieImportMock).not.toHaveBeenCalled() + expect(clearPendingCookieImportMock).not.toHaveBeenCalled() + expect(readdirSync(join(tmpDir, 'userData', 'cookie-import-staging'))).toEqual([]) + } finally { + platformSpy.mockRestore() + } + }) +}) + +function cookie(domain: string, name: string, path = '/') { + return { domain, name, path, secure: true, sameSite: 'unspecified', value: 'old' } +} + +function chromeBrowser(cookiesPath: string) { + return { + family: 'chrome' as const, + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} diff --git a/src/main/browser/browser-cookie-import-test-database.ts b/src/main/browser/browser-cookie-import-test-database.ts index ead377e5b46..1f38f0adc36 100644 --- a/src/main/browser/browser-cookie-import-test-database.ts +++ b/src/main/browser/browser-cookie-import-test-database.ts @@ -4,6 +4,7 @@ import { DatabaseSync } from 'node:sqlite' import { join } from 'node:path' type ChromiumCookieTestRow = { + domain?: string name: string value: string encryptedValue?: Buffer @@ -61,7 +62,7 @@ export function createChromiumCookieTestDatabase( rows.forEach((row, index) => { insert.run( 133_000_000_000_000 + index, - '.example.com', + row.domain ?? '.example.com', row.name, row.value, row.encryptedValue ?? Buffer.alloc(0), diff --git a/src/main/browser/browser-cookie-import.test.ts b/src/main/browser/browser-cookie-import.test.ts index 657215a6fbc..d657b8ec56e 100644 --- a/src/main/browser/browser-cookie-import.test.ts +++ b/src/main/browser/browser-cookie-import.test.ts @@ -160,14 +160,22 @@ function buildExpiredSafariCookie(index: number): Buffer { describe('importCookiesFromFile', () => { let tmpDir: string + let cookiesGetMock: ReturnType + let cookiesRemoveMock: ReturnType let cookiesSetMock: ReturnType beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), 'orca-cookie-test-')) + cookiesGetMock = vi.fn().mockResolvedValue([]) + cookiesRemoveMock = vi.fn().mockResolvedValue(undefined) cookiesSetMock = vi.fn().mockResolvedValue(undefined) sessionFromPartitionMock.mockReset() sessionFromPartitionMock.mockReturnValue({ - cookies: { set: cookiesSetMock } + cookies: { + get: cookiesGetMock, + remove: cookiesRemoveMock, + set: cookiesSetMock + } }) }) @@ -362,7 +370,7 @@ describe('importCookiesFromFile', () => { expect(cookiesSetMock.mock.calls[2][0].url).toBe('http://nodot.com/') }) - it('counts cookies that fail to set', async () => { + it('rolls back replacement when a cookie fails to set', async () => { cookiesSetMock.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('set failed')) const filePath = writeCookieFile([ @@ -371,12 +379,8 @@ describe('importCookiesFromFile', () => { ]) const result = await importCookiesFromFile(filePath, 'persist:test') - expect(result.ok).toBe(true) - if (!result.ok) { - return - } - expect(result.summary.importedCookies).toBe(1) - expect(result.summary.skippedCookies).toBe(1) + expect(result.ok).toBe(false) + expect(cookiesRemoveMock).toHaveBeenCalledWith('http://a.com/', 'ok') }) }) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 23bdd248777..131adcca66b 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines -- Why: cookie import is one pipeline (detect → decrypt → stage → swap) that must stay together to keep encryption/schema/staging in sync. */ -import { app, type BrowserWindow, dialog, session } from 'electron' +import { app, type BrowserWindow, type Cookie, dialog, session } from 'electron' import { execFileSync } from 'node:child_process' import { createDecipheriv, pbkdf2Sync, randomUUID } from 'node:crypto' import { @@ -75,6 +75,14 @@ import type { } from '../../shared/types' import { browserSessionRegistry } from './browser-session-registry' import { setupClientHintsOverride } from './browser-session-ua' +import { + isGoogleSourceBoundCookie, + normalizeCookieDomain, + normalizeCookieImportDomain, + replaceCookiesForImportedDomains, + restoreImportedDomainCookies, + type CookieImportMode +} from './browser-cookie-import-policy' import { createChromiumCookieSnapshot, type ChromiumCookieSnapshot @@ -500,13 +508,13 @@ function normalizeSameSite(raw: unknown): 'unspecified' | 'no_restriction' | 'la // Why: cookies.set() needs a url to scope the cookie; derive it from domain + secure flag. function deriveUrl(domain: string, secure: boolean): string | null { - const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain - if (!cleanDomain || cleanDomain.includes(' ')) { + const normalizedDomain = normalizeCookieDomain(domain) + if (!normalizedDomain) { return null } const protocol = secure ? 'https' : 'http' try { - const url = new URL(`${protocol}://${cleanDomain}/`) + const url = new URL(`${protocol}://${normalizedDomain}/`) return url.toString() } catch { return null @@ -552,40 +560,79 @@ function validateCookieEntry(raw: RawCookieEntry): ValidatedCookie | null { async function importValidatedCookies( cookies: ValidatedCookie[], totalInput: number, - targetPartition: string + targetPartition: string, + mode: CookieImportMode ): Promise { + const importDomainCache = new Map() + const validDomainCookies = cookies.filter((cookie) => { + let valid = importDomainCache.get(cookie.domain) + if (valid === undefined) { + valid = normalizeCookieImportDomain(cookie.domain) !== null + importDomainCache.set(cookie.domain, valid) + } + return valid + }) + const importableCookies = validDomainCookies.filter( + (cookie) => !isGoogleSourceBoundCookie(cookie.name, cookie.domain) + ) + const integritySkipped = validDomainCookies.length - importableCookies.length + const invalidDomainSkipped = cookies.length - validDomainCookies.length diag( - `importValidatedCookies: ${cookies.length} validated of ${totalInput} total, partition="${targetPartition}"` + `importValidatedCookies: ${cookies.length} validated, ${invalidDomainSkipped} unsafe-domain skipped, ${integritySkipped} source-bound skipped of ${totalInput} total, partition="${targetPartition}"` ) const targetSession = session.fromPartition(targetPartition) let importedCount = 0 - let skipped = totalInput - cookies.length + let skipped = totalInput - importableCookies.length const domainSet = new Set() + let replacedCookies: Cookie[] | null = null + + if (mode === 'replace-imported-domains' && importableCookies.length > 0) { + try { + replacedCookies = await replaceCookiesForImportedDomains( + targetSession.cookies, + importableCookies.map((cookie) => cookie.domain) + ) + diag(` removed ${replacedCookies.length} existing cookies in imported domain scopes`) + } catch (err) { + diag(` existing cookie replacement failed: ${summarizeCookieImportError(err)}`) + return { + ok: false, + reason: reasonWithDiagLog('Could not replace existing cookies for the imported sites.') + } + } + } // Why: Electron's cookies.set() rejects any non-printable-ASCII byte; strip as a safety net. const stripNonPrintable = (s: string): string => s.replace(/[^\x20-\x7E]/g, '') + const importedCookieKeys: { url: string; name: string }[] = [] + let setFailure: unknown = null - for (const cookie of cookies) { + for (const cookie of importableCookies) { try { // Why: Chromium rejects __Host- cookies unless they omit domain and use path=/. const isHostPrefixed = cookie.name.startsWith('__Host-') + const path = isHostPrefixed ? '/' : cookie.path await targetSession.cookies.set({ url: cookie.url, name: cookie.name, value: stripNonPrintable(cookie.value), ...(isHostPrefixed ? {} : { domain: cookie.domain }), - path: isHostPrefixed ? '/' : cookie.path, + path, secure: cookie.secure, httpOnly: cookie.httpOnly, sameSite: cookie.sameSite, expirationDate: cookie.expirationDate }) + const removalUrl = new URL(cookie.url) + removalUrl.pathname = path.startsWith('/') ? path : '/' + importedCookieKeys.push({ url: removalUrl.toString(), name: cookie.name }) importedCount++ // Why: surface only the domain (never name/value/path) so the summary doesn't leak secret cookie data. const cleanDomain = cookie.domain.startsWith('.') ? cookie.domain.slice(1) : cookie.domain domainSet.add(cleanDomain) } catch (err) { skipped++ + setFailure = err if (skipped <= 5) { // Find the exact offending character position and code const val = cookie.value @@ -601,6 +648,32 @@ async function importValidatedCookies( ` cookie.set FAILED: domain=${cookie.domain} name=${cookie.name} valLen=${val.length} badChar=${badInfo} err=${String(err)}` ) } + if (replacedCookies) { + break + } + } + } + + if (setFailure && replacedCookies) { + const rollbackFailures: unknown[] = [] + for (const cookie of importedCookieKeys.toReversed()) { + try { + await targetSession.cookies.remove(cookie.url, cookie.name) + } catch (err) { + rollbackFailures.push(err) + } + } + try { + await restoreImportedDomainCookies(targetSession.cookies, replacedCookies) + } catch (err) { + rollbackFailures.push(err) + } + if (rollbackFailures.length > 0) { + diag(` cookie replacement rollback failed: ${rollbackFailures.length} operation(s)`) + } + return { + ok: false, + reason: reasonWithDiagLog('Could not safely replace cookies for the imported sites.') } } @@ -690,7 +763,12 @@ export async function importCookiesFromFile( } } - return importValidatedCookies(validated, parsed.length, targetPartition) + return importValidatedCookies( + validated, + parsed.length, + targetPartition, + 'replace-imported-domains' + ) } // --------------------------------------------------------------------------- @@ -1338,7 +1416,12 @@ async function importCookiesFromFirefox( return { ok: false, reason: 'No valid cookies found in Firefox.' } } - return importValidatedCookies(validated, rows.length, targetPartition) + return importValidatedCookies( + validated, + rows.length, + targetPartition, + 'replace-imported-domains' + ) } catch (err) { rmSync(tmpDir, { recursive: true, force: true }) diag(` Firefox import failed: ${String(err)}`) @@ -1392,7 +1475,12 @@ async function importCookiesFromSafari( return { ok: false, reason: 'All Safari cookies are expired.' } } - return importValidatedCookies(valid, cookies.length, targetPartition) + return importValidatedCookies( + valid, + cookies.length, + targetPartition, + 'replace-imported-domains' + ) } catch (err) { diag(` Safari import failed: ${String(err)}`) return { ok: false, reason: 'Could not import cookies from Safari.' } @@ -1572,22 +1660,6 @@ export async function importCookiesFromBrowser( } } - // Why: Google integrity cookies are bound to the source browser's TLS/env; importing them triggers CookieMismatch, so skip and let Google reissue. - const INTEGRITY_COOKIE_NAMES = new Set([ - 'SIDCC', - '__Secure-1PSIDCC', - '__Secure-3PSIDCC', - '__Secure-STRP', - 'AEC' - ]) - function isIntegrityCookie(name: string, domain: string): boolean { - if (!INTEGRITY_COOKIE_NAMES.has(name)) { - return false - } - const d = domain.startsWith('.') ? domain.slice(1) : domain - return d === 'google.com' || d.endsWith('.google.com') - } - let imported = 0 let skipped = 0 let integritySkipped = 0 @@ -1608,6 +1680,7 @@ export async function importCookiesFromBrowser( } const decryptedCookies: DecryptedCookie[] = [] + const sourceDomainValidity = new Map() // Why: staging only backs the cold-restart replay, so any failure writing it disables that // fallback instead of aborting an import whose in-memory half still works. @@ -1658,11 +1731,21 @@ export async function importCookiesFromBrowser( const domain = sourceRow.host_key as string const name = sourceRow.name as string - if (isIntegrityCookie(name, domain)) { + if (isGoogleSourceBoundCookie(name, domain)) { integritySkipped++ continue } + let validDomain = sourceDomainValidity.get(domain) + if (validDomain === undefined) { + validDomain = normalizeCookieImportDomain(domain) !== null + sourceDomainValidity.set(domain, validDomain) + } + if (!validDomain) { + skipped++ + continue + } + const cleanDomain = domain.startsWith('.') ? domain.slice(1) : domain domainSet.add(cleanDomain) @@ -1704,6 +1787,21 @@ export async function importCookiesFromBrowser( } diag(` skipped ${integritySkipped} Google integrity cookies (SIDCC/STRP/AEC)`) + if (decryptedCookies.length === 0) { + closeStagingDb() + discardStagingFile() + return { + ok: true, + profileId: '', + summary: { + totalCookies: sourceRows.length, + importedCookies: 0, + skippedCookies: skipped + integritySkipped, + domains: [] + } + } + } + if (stagingDb) { try { stagingDb.exec('COMMIT') @@ -1786,7 +1884,7 @@ export async function importCookiesFromBrowser( const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, importedCookies: imported, - skippedCookies: skipped, + skippedCookies: skipped + integritySkipped, domains: [...domainSet].sort(), ...(warning ? { warning } : {}) } diff --git a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts index 19bc71dac23..d137a65632e 100644 --- a/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts +++ b/src/main/codex-accounts/runtime-home-mirrored-status-home.test.ts @@ -20,12 +20,7 @@ beforeEach(() => { testState.home = mkdtempSync(join(tmpdir(), 'orca-codex-status-home-')) // Why: the real-home check consults CODEX_HOME and the shell rc, so a // developer who exports one would otherwise fail this suite locally. - for (const key of [ - 'ORCA_USER_DATA_PATH', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', - 'CODEX_HOME', - 'ORCA_CODEX_HOME' - ]) { + for (const key of ['ORCA_USER_DATA_PATH', 'CODEX_HOME', 'ORCA_CODEX_HOME']) { previousEnv[key] = process.env[key] delete process.env[key] } @@ -91,8 +86,8 @@ describe('CodexRuntimeHomeService.getMirroredHostHomePathForStatus', () => { expect(service.getMirroredHostHomePathForStatus()).toBe(account.managedHomePath) }) - it('returns the shared runtime home when the real-home lane is off', async () => { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = '0' + it('returns the shared runtime home when a custom CODEX_HOME keeps the mirror lane', async () => { + process.env.CODEX_HOME = join(testState.home, 'custom-codex-home') const { CodexRuntimeHomeService } = await import('./runtime-home-service') const { getOrcaManagedCodexHomePath } = await import('../codex/codex-home-paths') const service = new CodexRuntimeHomeService(createStore([], null) as never) diff --git a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts index 4012cf3b10c..0efe90bb77c 100644 --- a/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts +++ b/src/main/codex-accounts/runtime-home-service-per-account-migration.test.ts @@ -21,7 +21,6 @@ beforeEach(() => { testState.home = mkdtempSync(join(tmpdir(), 'orca-codex-e-home-')) for (const key of [ 'ORCA_USER_DATA_PATH', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_DISABLE_CODEX_TRUST_RPC', 'CODEX_HOME', 'ORCA_CODEX_HOME' diff --git a/src/main/codex-accounts/runtime-home-service.test.ts b/src/main/codex-accounts/runtime-home-service.test.ts index 693ac8db7cf..8d3a52fa823 100644 --- a/src/main/codex-accounts/runtime-home-service.test.ts +++ b/src/main/codex-accounts/runtime-home-service.test.ts @@ -17,13 +17,13 @@ import { import { tmpdir } from 'node:os' import { join } from 'node:path' import type { CodexManagedAccount, GlobalSettings } from '../../shared/types' -import type * as NodeFs from 'node:fs' import type * as ShellStartupEnv from '../pty/shell-startup-env' const testState = { userDataDir: '', fakeHomeDir: '', - previousUserDataPath: undefined as string | undefined + previousUserDataPath: undefined as string | undefined, + shellStartupEnvProbeSupported: true } vi.mock('electron', () => ({ @@ -40,34 +40,24 @@ vi.mock('node:os', async () => { } }) -// Why: the shipped code no longer reads a settings flag — the legacy mirror -// lane is reachable only through the test-rig env override. Route the old -// per-test override key to that env var so this suite's lane coverage and -// mid-test flips keep exercising real transitions. +// Why: the shared system-default mirror is still live wherever the shell-startup +// probe is unavailable (Windows), so drive this suite's lane coverage and +// mid-test flips through that real gate rather than a test-only override. type TestSettingsOverrides = Partial & { - codexSystemDefaultRealHomeEnabled?: boolean + shellStartupEnvProbeSupported?: boolean } -function setRealHomeLaneForTest(enabled: boolean): void { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = enabled ? '1' : '0' +function setShellStartupEnvProbeSupportedForTest(enabled: boolean): void { + testState.shellStartupEnvProbeSupported = enabled } -const initialRealHomeLaneEnv = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME -afterEach(() => { - if (initialRealHomeLaneEnv === undefined) { - delete process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME - } else { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = initialRealHomeLaneEnv - } -}) - function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false - // Managed-home tests assert the mirror path; production is real-home always, - // so opt these managed cases out unless a test overrides it. - setRealHomeLaneForTest(overrides.codexSystemDefaultRealHomeEnabled ?? false) + // Mirror-path tests assert the shared runtime home, which production still uses + // on Windows; opt these cases onto that lane unless a test overrides it. + setShellStartupEnvProbeSupportedForTest(overrides.shellStartupEnvProbeSupported ?? false) return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -320,9 +310,10 @@ describe('CodexRuntimeHomeService', () => { beforeEach(() => { vi.resetModules() vi.clearAllMocks() + testState.shellStartupEnvProbeSupported = true vi.doMock('../pty/shell-startup-env', async () => ({ ...(await vi.importActual('../pty/shell-startup-env')), - isShellStartupEnvProbeSupported: () => true + isShellStartupEnvProbeSupported: () => testState.shellStartupEnvProbeSupported })) testState.userDataDir = mkdtempSync(join(tmpdir(), 'orca-runtime-home-')) testState.fakeHomeDir = mkdtempSync(join(tmpdir(), 'orca-codex-home-')) @@ -367,503 +358,6 @@ describe('CodexRuntimeHomeService', () => { } }) - it('materializes the active managed account auth into the runtime home on startup', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') - expect( - existsSync(join(testState.userDataDir, 'codex-runtime-home', 'system-default-auth.json')) - ).toBe(false) - }) - - it('deselects a shared-home account on settled credential-free JSON', async () => { - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-user', - workspaceLabel: null, - workspaceAccountId: 'acct-user', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - store.updateSettings.mockClear() - - writeFileSync(join(managedHomePath, 'auth.json'), '{}\n', 'utf-8') - service.syncForCurrentSelection() - - expect(store.getSettings().activeCodexManagedAccountId).toBeNull() - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull() - expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe('{}\n') - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-runtime-home] Active managed account credential is unavailable, restoring system default' - ) - }) - - it('deselects a shared-home account when unreadable auth outlives the grace window', async () => { - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-user', - workspaceLabel: null, - workspaceAccountId: 'acct-user', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - store.updateSettings.mockClear() - writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') - const observedAt = Date.now() - const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(observedAt) - - service.syncForCurrentSelection() - expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') - expect(store.updateSettings).not.toHaveBeenCalled() - - nowSpy.mockReturnValue(observedAt + 6_000) - service.syncForCurrentSelection() - nowSpy.mockRestore() - expect(store.getSettings().activeCodexManagedAccountId).toBeNull() - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBeNull() - }) - - it.each([ - ['missing', (authPath: string) => rmSync(authPath, { force: true })], - [ - 'unreadable', - (authPath: string) => { - chmodSync(authPath, 0o000) - } - ] - ])( - 'never launches the previously synced account when the selected account auth is %s', - async (label, breakAuth) => { - if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) { - return - } - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const authB = createCodexAuthJson('b@example.com', 'acct-b', 'b') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-a', - email: 'a@example.com', - managedHomePath: homeA, - providerAccountId: 'acct-a', - workspaceLabel: null, - workspaceAccountId: 'acct-a', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-b', - email: 'b@example.com', - managedHomePath: homeB, - providerAccountId: 'acct-b', - workspaceLabel: null, - workspaceAccountId: 'acct-b', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-a', - activeCodexManagedAccountIdsByRuntime: { host: 'account-a', wsl: {} } - }) - ) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(authA) - - store.updateSettings({ - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - breakAuth(join(homeB, 'auth.json')) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - - // The selection survives the grace window, but the launch must never run - // as account A while the UI says account B. - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( - authA - ) - expect(warnSpy).toHaveBeenCalledWith( - '[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth' - ) - } - ) - - it.each([ - ['missing', (authPath: string) => rmSync(authPath, { force: true })], - [ - 'unreadable', - (authPath: string) => { - chmodSync(authPath, 0o000) - } - ] - ])( - 'preserves the mirrored refresh of the account the runtime home actually holds when the selected account auth is %s', - async (label, breakAuth) => { - if (label === 'unreadable' && (process.platform === 'win32' || process.getuid?.() === 0)) { - return - } - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a', 1) - const authARefreshed = createCodexAuthJson('a@example.com', 'acct-a', 'a-refreshed', 2) - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // A crash left account A's refreshed tokens in the mirror while settings - // already select account B, whose own auth.json cannot be read. - writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, - 'utf-8' - ) - breakAuth(join(homeB, 'auth.json')) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-a', - email: 'a@example.com', - managedHomePath: homeA, - providerAccountId: 'acct-a', - workspaceLabel: null, - workspaceAccountId: 'acct-a', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-b', - email: 'b@example.com', - managedHomePath: homeB, - providerAccountId: 'acct-b', - workspaceLabel: null, - workspaceAccountId: 'acct-b', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - } - ) - - it('keeps the runtime auth through the grace window while the selected account rotates', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-user', - workspaceLabel: null, - workspaceAccountId: 'acct-user', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - - // Codex rewrites the selected account's auth.json in place: the runtime home - // already holds that same account, so its credentials must survive the race. - writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') - }) - - it('keeps runtime auth the selected account still owns when provenance is unreadable', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const managedAuth = createCodexAuthJson('user@example.com', 'acct-user', 'managed') - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - // Cold start: the mirror holds the selected account's own credential, but a - // torn provenance file can no longer say so and its auth.json reads torn. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, managedAuth, 'utf-8') - writeFileSync(getSharedRuntimeAuthProvenancePath(), 'not-json', 'utf-8') - writeFileSync(join(managedHomePath, 'auth.json'), '{"tokens":{"acc', 'utf-8') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath) - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') - }) - - it.each([ - ['no provenance', (path: string) => rmSync(path, { force: true })], - [ - 'selected-account provenance', - (path: string) => - writeFileSync( - path, - `${JSON.stringify({ owner: 'managed', accountId: 'account-b' })}\n`, - 'utf-8' - ) - ] - ])('never launches another account on a cold start with %s', async (_label, setProvenance) => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // A retained pane can overwrite the mirror after B's provenance commits. - // Neither missing nor stale provenance can overrule the current bytes. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, authA, 'utf-8') - setProvenance(getSharedRuntimeAuthProvenancePath()) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( - authA - ) - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authA) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - }) - - it('persists a mirror-only system-default refresh before dropping unproven runtime auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system', 1) - const systemAuthRefreshed = createCodexAuthJson( - 'system@example.com', - 'acct-system', - 'system-refreshed', - 2 - ) - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // Codex rotated the system-default credential inside the mirror; ~/.codex is - // still the older copy Orca mirrored, so the refresh lives only here. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, systemAuthRefreshed, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'system-default', authJson: systemAuth })}\n`, - 'utf-8' - ) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuthRefreshed) - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - - it('blocks launch when a readable stale runtime auth cannot be deleted', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // A Windows sharing violation can deny delete while still allowing Codex to - // read the credential, so model those operations independently. - writeFileSync(runtimeAuthPath, authA, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, - 'utf-8' - ) - rmSync(join(homeB, 'auth.json'), { force: true }) - vi.doMock('node:fs', async () => { - const actual = await vi.importActual('node:fs') - return { - ...actual, - rmSync: (path: Parameters[0], options?: Parameters[1]) => { - if (path === runtimeAuthPath) { - throw new Error('simulated Windows sharing violation') - } - return actual.rmSync(path, options) - } - } - }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(() => service.prepareForCodexLaunch()).toThrow( - 'Cannot safely launch Codex while stale runtime auth remains.' - ) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(authA) - expect(JSON.parse(readFileSync(getSharedRuntimeAuthProvenancePath(), 'utf-8'))).toEqual({ - owner: 'fenced' - }) - }) - it('refuses to read runtime auth back into a duplicate account while a home is unreadable', async () => { if (process.platform === 'win32' || process.getuid?.() === 0) { return @@ -911,153 +405,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(homeX2, 'auth.json'), 'utf-8')).toBe(authX) }) - it('drops another account from the mirror even when the fence write fails', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('a@example.com', 'acct-a', 'a') - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // Stands in for a Windows AV lock on the provenance file: the fence cannot - // be written, while auth.json itself is perfectly deletable. - mkdirSync(getRuntimeCodexHomePath(), { recursive: true }) - writeFileSync(runtimeAuthPath, authA, 'utf-8') - rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) - mkdirSync(getSharedRuntimeAuthProvenancePath(), { recursive: true }) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(runtimeAuthPath) ? readFileSync(runtimeAuthPath, 'utf-8') : null).not.toBe( - authA - ) - }) - - it('keeps an unattributable api-key credential Orca mirrored in the same run', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const apiKeyAuth = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-managed' })}\n` - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', apiKeyAuth) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-1', 'user@example.com', 'acct-user', managedHomePath) - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Exact same-run bytes prove ownership even without identity claims. - writeFileSync(join(managedHomePath, 'auth.json'), '{"OPENAI_API_K', 'utf-8') - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(apiKeyAuth) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-1') - }) - - it('drops a pre-provenance api-key credential when the selected owner cannot be proven', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-a' })}\n` - const authB = `${JSON.stringify({ auth_mode: 'apikey', OPENAI_API_KEY: 'sk-b' })}\n` - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth(testState.userDataDir, 'account-b', authB) - writeFileSync(runtimeAuthPath, authA, 'utf-8') - rmSync(getSharedRuntimeAuthProvenancePath(), { force: true }) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'a@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authA) - expect(store.getSettings().activeCodexManagedAccountIdsByRuntime?.host).toBe('account-b') - }) - - it('reads a renamed account refresh back into its own home despite a stale record email', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync( - getSystemCodexAuthPath(), - createCodexAuthJson('system@example.com', 'acct-system', 'system'), - 'utf-8' - ) - const authA = createCodexAuthJson('new@example.com', 'acct-a', 'a', 1) - const authARefreshed = createCodexAuthJson('new@example.com', 'acct-a', 'a-refreshed', 2) - const homeA = createManagedAuth(testState.userDataDir, 'account-a', authA) - const homeB = createManagedAuth( - testState.userDataDir, - 'account-b', - createCodexAuthJson('b@example.com', 'acct-b', 'b') - ) - // Account A was renamed on the ChatGPT side after it was added, so its record - // email is stale while the mirrored refresh carries the new one. - writeFileSync(runtimeAuthPath, authARefreshed, 'utf-8') - writeFileSync( - getSharedRuntimeAuthProvenancePath(), - `${JSON.stringify({ owner: 'managed', accountId: 'account-a' })}\n`, - 'utf-8' - ) - rmSync(join(homeB, 'auth.json'), { force: true }) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - createCodexAccountRecord('account-a', 'old@example.com', 'acct-a', homeA), - createCodexAccountRecord('account-b', 'b@example.com', 'acct-b', homeB) - ], - activeCodexManagedAccountId: 'account-b', - activeCodexManagedAccountIdsByRuntime: { host: 'account-b', wsl: {} } - }) - ) - vi.spyOn(console, 'warn').mockImplementation(() => {}) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(join(homeA, 'auth.json'), 'utf-8')).toBe(authARefreshed) - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - it('keeps the mirror of a renamed account whose record email is stale', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync( @@ -1108,29 +455,7 @@ describe('CodexRuntimeHomeService', () => { process.platform === 'win32' ? 'junction' : undefined ) writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) @@ -1139,7 +464,7 @@ describe('CodexRuntimeHomeService', () => { normalizeLinkTarget(getRuntimeCodexHomePath()) ) expect(readFileSync(join(legacyActiveHomePath, 'auth.json'), 'utf-8')).toBe( - '{"account":"managed"}\n' + '{"account":"system"}\n' ) }) @@ -1540,42 +865,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system"}\n') }) - it('removes runtime auth when restoring a no-login system default', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') - - settings.activeCodexManagedAccountId = null - service.syncForCurrentSelection() - - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - it('removes runtime auth when deselecting with a missing system-default snapshot', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') @@ -1652,9 +941,7 @@ describe('CodexRuntimeHomeService', () => { }) }) - it('clears an invalid active account selection and removes untrusted runtime auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') + it('clears an active account selection whose self-contained home is missing', async () => { const missingManagedHomePath = join( testState.userDataDir, 'codex-accounts', @@ -1686,47 +973,6 @@ describe('CodexRuntimeHomeService', () => { expect(store.updateSettings).toHaveBeenCalledWith( expect.objectContaining({ activeCodexManagedAccountId: null }) ) - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(warnSpy).toHaveBeenCalled() - }) - - it('clears an invalid active account selection and restores live system default auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const systemAuth = '{"account":"system"}\n' - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - rmSync(runtimeAuthPath, { force: true }) - const missingManagedHomePath = join( - testState.userDataDir, - 'codex-accounts', - 'account-1', - 'home' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath: missingManagedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(store.updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ activeCodexManagedAccountId: null }) - ) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) expect(warnSpy).toHaveBeenCalled() }) @@ -1760,14 +1006,15 @@ describe('CodexRuntimeHomeService', () => { const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(existsSync(markerPath)).toBe(false) + // Why: a mirror launch never leaves the real home, so its backfill stays valid. + expect(existsSync(markerPath)).toBe(true) expect(service.prepareForRateLimitFetch()).toBe(getRuntimeCodexHomePath()) expect(service.getHostCodexHomePathsForSessionDiscovery()).toEqual([getRuntimeCodexHomePath()]) expect(existsSync(getRuntimeCodexHomePath())).toBe(true) }) - it('routes host system default to the real home when the flag is ON', async () => { - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + it('routes host system default to the real home', async () => { + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -1843,7 +1090,7 @@ describe('CodexRuntimeHomeService', () => { it('seeds shared auth for a pane-local custom home on the real-home lane', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const customHome = join(testState.fakeHomeDir, 'pane-custom-codex-home') @@ -1878,7 +1125,7 @@ describe('CodexRuntimeHomeService', () => { `${JSON.stringify({ owner: 'system-default', authJson: retainedAuth })}\n`, 'utf-8' ) - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) try { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -1909,12 +1156,12 @@ describe('CodexRuntimeHomeService', () => { '' ].join('\n') writeFileSync(getSystemCodexAuthPath(), oldSystemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(oldSystemAuth) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') writeFileSync(join(getSystemCodexHomePath(), 'config.toml'), systemConfig, 'utf-8') writeFileSync( @@ -1940,12 +1187,12 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') const managedAuth = createCodexAuthJson('managed@example.com', 'acct-managed', 'managed') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) const restartedService = new CodexRuntimeHomeService(store as never) restartedService.setRealHomeLaneGate(() => true) @@ -1954,43 +1201,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe(systemAuth) }) - it('preserves same-identity retained managed auth after restart and source logout', async () => { - const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') - const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'shared@example.com', - managedHomePath, - providerAccountId: 'acct-shared', - workspaceLabel: null, - workspaceAccountId: 'acct-shared', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ] - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - settings.activeCodexManagedAccountId = 'account-1' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } - service.syncForCurrentSelection() - - settings.activeCodexManagedAccountId = null - settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } - setRealHomeLaneForTest(true) - rmSync(getSystemCodexAuthPath()) - const restartedService = new CodexRuntimeHomeService(store as never) - - expect(restartedService.prepareForCodexLaunch()).toBeNull() - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(managedAuth) - }) - it('fences retained shared auth when a self-contained managed transition begins', async () => { const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') const managedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'managed') @@ -2015,7 +1225,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) settings.activeCodexManagedAccountId = 'account-1' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } service.syncForCurrentSelection() @@ -2062,7 +1272,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) settings.activeCodexManagedAccountId = 'account-1' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } service.syncForCurrentSelection() @@ -2100,14 +1310,14 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) settings.activeCodexManagedAccountId = 'account-1' settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-1', wsl: {} } service.syncForCurrentSelection() writeFileSync(getRuntimeCodexAuthPath(), managedAuth, 'utf-8') settings.activeCodexManagedAccountId = null settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } - setRealHomeLaneForTest(false) + setShellStartupEnvProbeSupportedForTest(false) service.syncForCurrentSelection() expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) @@ -2121,12 +1331,12 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) @@ -2136,13 +1346,13 @@ describe('CodexRuntimeHomeService', () => { it('does not rewrite retained-auth provenance during unchanged rate polling', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) const provenancePath = getSharedRuntimeAuthProvenancePath() const originalInode = statSync(provenancePath).ino - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.prepareForRateLimitFetch() service.prepareForRateLimitFetch() @@ -2152,10 +1362,10 @@ describe('CodexRuntimeHomeService', () => { it('does not rewrite completed retained logout metadata during rate polling', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) service.prepareForRateLimitFetch() const metadataPaths = [ @@ -2213,7 +1423,7 @@ describe('CodexRuntimeHomeService', () => { ) settings.activeCodexManagedAccountId = null settings.activeCodexManagedAccountIdsByRuntime = { host: null, wsl: {} } - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.syncForCurrentSelection() writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') @@ -2232,7 +1442,7 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: true })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') @@ -2247,11 +1457,11 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const retainedAuth = createCodexAuthJson('system@example.com', 'acct-system', 'retained-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) const service = new CodexRuntimeHomeService(store as never) writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') rmSync(getSystemCodexAuthPath()) @@ -2268,7 +1478,7 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync( @@ -2280,7 +1490,7 @@ describe('CodexRuntimeHomeService', () => { })}\n` ) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(refreshedSystemAuth) @@ -2289,14 +1499,14 @@ describe('CodexRuntimeHomeService', () => { it('recovers runtime auth quarantined by an interrupted guarded update', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) const runtimeAuthPath = getRuntimeCodexAuthPath() const heldAuthPath = `${runtimeAuthPath}.orca-guarded` renameSync(runtimeAuthPath, heldAuthPath) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) new CodexRuntimeHomeService(store as never) expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) @@ -2307,7 +1517,7 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'system') const retainedAuth = createCodexAuthJson('shared@example.com', 'acct-shared', 'retained') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync( @@ -2320,7 +1530,7 @@ describe('CodexRuntimeHomeService', () => { ) writeFileSync(getRuntimeCodexAuthPath(), retainedAuth, 'utf-8') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(retainedAuth) @@ -2334,12 +1544,12 @@ describe('CodexRuntimeHomeService', () => { 'refreshed-token' ) writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) writeFileSync(getSharedRuntimeAuthProvenancePath(), '{"owner":"pending"}\n') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) writeFileSync(getSystemCodexAuthPath(), refreshedSystemAuth, 'utf-8') expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(systemAuth) @@ -2349,11 +1559,11 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) const service = new CodexRuntimeHomeService(store as never) rmSync(getSystemCodexAuthPath()) expect(service.prepareForRateLimitFetch()).toBe(getSystemCodexHomePath()) @@ -2373,11 +1583,11 @@ describe('CodexRuntimeHomeService', () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) rmSync(getRuntimeCodexAuthPath()) if (removeProvenance) { @@ -2408,7 +1618,7 @@ describe('CodexRuntimeHomeService', () => { createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') ) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: false, + shellStartupEnvProbeSupported: false, codexManagedAccounts: [ { id: 'account-1', @@ -2428,7 +1638,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) rmSync(getSystemCodexAuthPath()) rmSync(getRuntimeCodexAuthPath()) rmSync( @@ -2458,14 +1668,14 @@ describe('CodexRuntimeHomeService', () => { const systemConfigPath = join(getSystemCodexHomePath(), 'config.toml') const runtimeConfigPath = join(getRuntimeCodexHomePath(), 'config.toml') writeFileSync(systemConfigPath, 'model = "baseline"\n', 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) writeFileSync(runtimeConfigPath, 'model = "runtime-change"\n', 'utf-8') - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.setRealHomeLaneGate(() => false) service.reconcileLegacySharedHomeForRetainedPanes() expect(readFileSync(systemConfigPath, 'utf-8')).toBe('model = "baseline"\n') @@ -2476,7 +1686,7 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeConfigPath, 'utf-8')).toBe('model = "runtime-change"\n') }) - it('routes a host MANAGED account to its own self-contained home when the flag is ON', async () => { + it('routes a host MANAGED account to its own self-contained home', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const runtimeAuthPath = getRuntimeCodexAuthPath() const managedHomePath = createManagedAuth( @@ -2486,7 +1696,7 @@ describe('CodexRuntimeHomeService', () => { ) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2507,7 +1717,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Flag ON + host managed account = the account's own home is CODEX_HOME. + // A host managed account's own home is its CODEX_HOME. expect(service.isHostSystemDefaultRealHome()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) // The per-account home keeps its own auth in place; the shared mirror's @@ -2520,14 +1730,14 @@ describe('CodexRuntimeHomeService', () => { expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(managedHomePath) }) - it('gives two managed accounts distinct homes without racing one auth.json (flag ON)', async () => { + it('gives two managed accounts distinct homes without racing one auth.json', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) const home2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2579,7 +1789,7 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) }) - it('materializes resources and config into the per-account home on launch (flag ON)', async () => { + it('materializes resources and config into the per-account home on launch', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') mkdirSync(join(getSystemCodexHomePath(), 'skills', 'review'), { recursive: true }) writeFileSync( @@ -2599,7 +1809,7 @@ describe('CodexRuntimeHomeService', () => { ) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2629,11 +1839,11 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(getSystemCodexAuthPath(), 'utf-8')).toBe('{"account":"system"}\n') }) - it('points the rate-limit fetch at the per-account home when the flag is ON', async () => { + it('points the rate-limit fetch at the per-account home', async () => { const home1 = createManagedAuth(testState.userDataDir, 'account-1', '{"account":"managed"}\n') const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2657,14 +1867,14 @@ describe('CodexRuntimeHomeService', () => { expect(service.prepareForRateLimitFetch()).toBe(home1) }) - it('preserves a managed selection whose auth.json is temporarily missing (flag ON)', async () => { + it('preserves a managed selection whose auth.json is temporarily missing', async () => { writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') // A managed home that has lost its auth.json (only the marker remains). const brokenHome = join(testState.userDataDir, 'codex-accounts', 'account-1', 'home') mkdirSync(brokenHome, { recursive: true }) writeFileSync(join(brokenHome, '.orca-managed-home'), 'account-1\n', 'utf-8') const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2690,41 +1900,9 @@ describe('CodexRuntimeHomeService', () => { expect(store.getSettings().activeCodexManagedAccountId).toBe('account-1') }) - it('keeps the shared runtime home + auth hot-swap for managed accounts when the flag is OFF', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') - const home1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const store = createStore( - createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Flag OFF is byte-identical to today: shared mirror home + hot-swapped auth. - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1Auth) - }) - - it('keeps pre-E shared-mirror sessions discoverable alongside per-account rollouts (flag ON)', async () => { + it('keeps pre-E shared-mirror sessions discoverable alongside per-account rollouts', async () => { // Pre-E history lives in the shared runtime mirror; after upgrading to - // per-account homes (flag ON) new rollouts land in the account's own home. + // per-account homes, new rollouts land in the account's own home. const sharedSessionsDir = join(getRuntimeCodexHomePath(), 'sessions', '2026', '07', '16') mkdirSync(sharedSessionsDir, { recursive: true }) writeFileSync(join(sharedSessionsDir, 'rollout-pre-e.jsonl'), '{"record":"pre-e"}\n', 'utf-8') @@ -2742,7 +1920,7 @@ describe('CodexRuntimeHomeService', () => { ) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2770,9 +1948,9 @@ describe('CodexRuntimeHomeService', () => { expect(discovery).toContain(home1) }) - it('surfaces per-account rollouts for session discovery after opting out (flag OFF)', async () => { - // The account ran with the flag ON and accumulated rollouts in its own home, - // then the user opted back out (flag OFF, byte-identical launch path). + it('surfaces per-account rollouts for session discovery on the mirror lane', async () => { + // A Windows host keeps the shared system-default mirror, but its managed + // accounts still launch from their own homes and accumulate rollouts there. const home1 = createManagedAuth( testState.userDataDir, 'account-1', @@ -2783,7 +1961,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(join(rolloutDir, 'rollout-e-era.jsonl'), '{"record":"e-era"}\n', 'utf-8') const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: false, + shellStartupEnvProbeSupported: false, codexManagedAccounts: [ { id: 'account-1', @@ -2804,118 +1982,10 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Opting out must not hide rollouts that already live in the per-account home. + // The mirror lane must not hide rollouts living in the per-account home. expect(service.getHostCodexHomePathsForSessionDiscovery()).toContain(home1) }) - it('does not scan an opted-out never-enabled account home that holds no rollouts', async () => { - // A pure never-enabled install keeps its per-account homes credential-only, so - // opt-out discovery must stay byte-identical to today (mirror home only). - const home1 = createManagedAuth( - testState.userDataDir, - 'account-1', - createCodexAuthJson('one@example.com', 'acct-1', 'one') - ) - const store = createStore( - createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(service.getHostCodexHomePathsForSessionDiscovery()).not.toContain(home1) - }) - - it('preserves a fresh per-account credential and propagates it to the shared mirror on opt-out', async () => { - // Flag ON refreshed the account's token in its own home; the shared mirror's - // auth is frozen from before the ON period. Opting out (flag OFF) must not let - // the stale mirror token clobber the fresher per-account credential. - const freshAuth = createCodexAuthJson('one@example.com', 'acct-1', 'fresh', 4_000) - const staleAuth = createCodexAuthJson('one@example.com', 'acct-1', 'stale', 1_000) - const home1 = createManagedAuth(testState.userDataDir, 'account-1', freshAuth) - // A stale mirror auth left over from before the account ran with the flag ON. - writeFileSync(getRuntimeCodexAuthPath(), staleAuth, 'utf-8') - const store = createStore( - createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - ) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - // Constructor runs the startup opt-out sync (read-back + hot-swap). - new CodexRuntimeHomeService(store as never) - - // The fresher per-account credential is preserved (never overwritten by the - // stale mirror read-back) and becomes the mirror's auth for the opted-out launch. - expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(freshAuth) - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(freshAuth) - }) - - it('rolls a live self-contained account back without adopting newer shared bytes', async () => { - const accountAuth = createCodexAuthJson('one@example.com', 'acct-1', 'in-place', 2_000) - const laterShared = createCodexAuthJson('one@example.com', 'acct-1', 'shared', 3_000) - const home1 = createManagedAuth(testState.userDataDir, 'account-1', accountAuth) - const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: home1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - expect(service.prepareForCodexLaunch()).toBe(home1) - writeFileSync(getRuntimeCodexAuthPath(), laterShared, 'utf-8') - - setRealHomeLaneForTest(false) - service.syncForCurrentSelection() - - expect(readFileSync(join(home1, 'auth.json'), 'utf-8')).toBe(accountAuth) - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(accountAuth) - }) - it('keeps per-account auth canonical when the real-home lane takes over', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'system') @@ -2929,7 +1999,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -2950,7 +2020,7 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - // Flag ON launches the account from its own home without populating the + // Launching the account from its own home never populates the // legacy shared mirror. expect(service.isHostSystemDefaultRealHome()).toBe(false) expect(service.prepareForCodexLaunch()).toBe(managedHomePath) @@ -2983,7 +2053,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3031,7 +2101,7 @@ describe('CodexRuntimeHomeService', () => { createCodexAuthJson('wsl@example.com', 'acct-wsl', 'wsl') ) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3138,7 +2208,7 @@ describe('CodexRuntimeHomeService', () => { const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', accountAuth) const store = createStore( createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3199,7 +2269,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3245,7 +2315,7 @@ describe('CodexRuntimeHomeService', () => { writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, + shellStartupEnvProbeSupported: true, codexManagedAccounts: [ { id: 'account-1', @@ -3281,328 +2351,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(join(managedHomePath, 'auth.json'), 'utf-8')).toBe(managedAuth) }) - it('uses the same host CODEX_HOME after switching managed Codex accounts', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - const account1Home = service.prepareForCodexLaunch() - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - const account2Home = service.prepareForCodexLaunch() - - expect(account1Home).toBe(getRuntimeCodexHomePath()) - expect(account2Home).toBe(getRuntimeCodexHomePath()) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('new host Codex launches get the selected account after preserving outgoing refreshes', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-1', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - settings.activeCodexManagedAccountId = 'account-2' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-2', wsl: {} } - - expect(service.prepareForCodexLaunch()).toBe(getRuntimeCodexHomePath()) - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('reads back selected-account refreshes without ambiguity from duplicate identities', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'same@example.com', - 'acct-same', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) - }) - - it('keeps fresher selected-account startup refreshes when duplicate identities exist', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'same@example.com', - 'acct-same', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) - }) - - it('routes stale live-pane startup refreshes to the matching account before restoring selected auth', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('one@example.com', 'acct-1', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-1', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-2', - activeCodexManagedAccountIdsByRuntime: { host: 'account-2', wsl: {} } - }) - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('preserves duplicate-identity outgoing refreshes before switching to another account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const account1Auth = createCodexAuthJson('same@example.com', 'acct-same', 'one', 1) - const account1RefreshedAuth = createCodexAuthJson( - 'same@example.com', - 'acct-same', - 'one-refreshed', - 2 - ) - const account2Auth = createCodexAuthJson('same@example.com', 'acct-same', 'two', 1) - const account3Auth = createCodexAuthJson('three@example.com', 'acct-3', 'three', 1) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', account3Auth) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - }, - { - id: 'account-3', - email: 'three@example.com', - managedHomePath: managedHomePath3, - providerAccountId: 'acct-3', - workspaceLabel: null, - workspaceAccountId: 'acct-3', - createdAt: 3, - updatedAt: 3, - lastAuthenticatedAt: 3 - } - ], - activeCodexManagedAccountId: 'account-1', - activeCodexManagedAccountIdsByRuntime: { host: 'account-1', wsl: {} } - }) - const store = createStore(settings) - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - settings.activeCodexManagedAccountId = 'account-3' - settings.activeCodexManagedAccountIdsByRuntime = { host: 'account-3', wsl: {} } - service.syncForCurrentSelection() - - expect(readFileSync(join(managedHomePath1, 'auth.json'), 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(join(managedHomePath2, 'auth.json'), 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account3Auth) - }) - it('mirrors later system Codex config changes before launch', async () => { const systemCodexHome = getSystemCodexHomePath() mkdirSync(systemCodexHome, { recursive: true }) @@ -3632,7 +2380,7 @@ describe('CodexRuntimeHomeService', () => { '' ].join('\n') writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: false })) + const store = createStore(createSettings({ shellStartupEnvProbeSupported: false })) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -4129,7 +2877,6 @@ describe('CodexRuntimeHomeService', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) - expect(readFileSync(getRuntimeCodexAuthPath(), 'utf-8')).toBe(hostAuth) expect(service.prepareForCodexLaunch({ runtime: 'wsl', wslDistro: 'Ubuntu' })).toBe( wslRuntimeHomePath ) @@ -4935,38 +3682,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(systemAuth) }) - it('removes untrusted runtime auth on restart when persisted active account is invalid', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath: join(testState.userDataDir, 'codex-accounts', 'account-1', 'home'), - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(store.updateSettings).toHaveBeenCalledWith( - expect.objectContaining({ activeCodexManagedAccountId: null }) - ) - expect(existsSync(runtimeAuthPath)).toBe(false) - expect(warnSpy).toHaveBeenCalled() - }) - it('imports legacy managed-home history into the shared runtime history', async () => { const runtimeHomePath = getRuntimeCodexHomePath() const runtimeHistoryPath = join(runtimeHomePath, 'history.jsonl') @@ -5000,30 +3715,8 @@ describe('CodexRuntimeHomeService', () => { } const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') new CodexRuntimeHomeService(store as never) @@ -5038,29 +3731,8 @@ describe('CodexRuntimeHomeService', () => { } const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) + writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) @@ -5143,385 +3815,6 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(snapshotPath)).toBe(false) }) - it('reads back CLI-refreshed tokens into managed storage on subsequent sync', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'refreshed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Simulate CLI refreshing the token in runtime CODEX_HOME/auth.json. - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - - // Next sync should read back the refreshed token to managed storage - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - }) - - it('rejects runtime read-back from a different Codex identity', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') - const staleLivePtyAuth = createCodexAuthJson('stale@example.com', 'acct-stale', 'stale') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'selected@example.com', - managedHomePath, - providerAccountId: 'acct-selected', - workspaceLabel: null, - workspaceAccountId: 'acct-selected', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Simulate an old live Codex PTY from another account refreshing the - // shared runtime auth after Orca has already selected account-1. - writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) - }) - - it('routes runtime read-back from a different Codex identity to its matching account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-one', - 'one-refreshed' - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-2' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // An older account-1 Codex process refreshed the shared runtime file after - // Orca selected account-2. Persist the refresh to account-1, then restore - // the selected account in runtime CODEX_HOME. - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2Auth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('rejects ambiguous Codex read-back instead of choosing a managed account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('same@example.com', 'acct-same', 'original') - const refreshedAuth = createCodexAuthJson('same@example.com', 'acct-same', 'refreshed') - const activeAuth = createCodexAuthJson('active@example.com', 'acct-active', 'active') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', originalAuth) - const managedHomePath3 = createManagedAuth(testState.userDataDir, 'account-3', activeAuth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'same@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'same@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-same', - workspaceLabel: null, - workspaceAccountId: 'acct-same', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - }, - { - id: 'account-3', - email: 'active@example.com', - managedHomePath: managedHomePath3, - providerAccountId: 'acct-active', - workspaceLabel: null, - workspaceAccountId: 'acct-active', - createdAt: 3, - updatedAt: 3, - lastAuthenticatedAt: 3 - } - ], - activeCodexManagedAccountId: 'account-3' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(originalAuth) - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(originalAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(activeAuth) - }) - - it('rejects runtime read-back without a positive selected-account identity match', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const selectedAuth = createCodexAuthJson('selected@example.com', 'acct-selected', 'selected') - const accountOnlyAuth = `${JSON.stringify({ - tokens: { - account_id: 'acct-stale', - refresh_token: 'stale' - } - })}\n` - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'selected@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, accountOnlyAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) - }) - - it('rejects same-email runtime read-back when account ids differ from sparse managed metadata', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const selectedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'selected') - const staleLivePtyAuth = createCodexAuthJson('user@example.com', 'acct-stale', 'stale') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', selectedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, staleLivePtyAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(selectedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(selectedAuth) - }) - - it('reads back same-account refreshes for sparse managed metadata using stored auth identity', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'original') - const refreshedAuth = createCodexAuthJson('user@example.com', 'acct-selected', 'refreshed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - }) - - it('reads back strong account-id refreshes when the runtime auth has no email', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const refreshedAuth = `${JSON.stringify({ - tokens: { - account_id: 'acct-1', - refresh_token: 'refreshed' - } - })}\n` - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, refreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(refreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) - }) - - it('rejects unverifiable Codex read-back on first sync after restart', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"credential":"refreshed-while-down"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"credential":"original"}\n' - ) - const managedAuthPath = join(managedHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe('{"credential":"original"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"credential":"original"}\n') - }) - it('reads back verified same-account refreshes on first sync after restart', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original', 1_000) @@ -5555,201 +3848,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(refreshedAuth) }) - it('rejects older same-account Codex auth on first sync after restart', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - const staleRuntimeAuth = createCodexAuthJson('user@example.com', 'acct-1', 'stale', 1_000) - const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed-newer', 2_000) - writeFileSync(runtimeAuthPath, staleRuntimeAuth, 'utf-8') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const store = createStore( - createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - ) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - new CodexRuntimeHomeService(store as never) - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(managedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(managedAuth) - }) - - it('does not contaminate the incoming Codex account during account switch', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const managedHomePath1 = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"credential":"account1"}\n' - ) - const managedHomePath2 = createManagedAuth( - testState.userDataDir, - 'account-2', - '{"credential":"account2"}\n' - ) - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user1@example.com', - managedHomePath: managedHomePath1, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'user2@example.com', - managedHomePath: managedHomePath2, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - settings.activeCodexManagedAccountId = 'account-2' - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe('{"credential":"account2"}\n') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"credential":"account2"}\n') - }) - - it('does not carry the reauth read-back skip across Codex account switches', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const account2RefreshedAuth = createCodexAuthJson( - 'two@example.com', - 'acct-two', - 'two-refreshed' - ) - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath2 = join(managedHomePath2, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - service.clearLastWrittenAuthJson() - settings.activeCodexManagedAccountId = 'account-2' - service.syncForCurrentSelection() - - writeFileSync(runtimeAuthPath, account2RefreshedAuth, 'utf-8') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath2, 'utf-8')).toBe(account2RefreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2RefreshedAuth) - }) - - it('does not apply inactive-account Codex reauth skip to the active account', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Auth = createCodexAuthJson('one@example.com', 'acct-one', 'one') - const account1RefreshedAuth = createCodexAuthJson( - 'one@example.com', - 'acct-one', - 'one-refreshed' - ) - const account2Auth = createCodexAuthJson('two@example.com', 'acct-two', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Auth) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-one', - workspaceLabel: null, - workspaceAccountId: 'acct-one', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-two', - workspaceLabel: null, - workspaceAccountId: 'acct-two', - createdAt: 2, - updatedAt: 2, - lastAuthenticatedAt: 2 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1RefreshedAuth, 'utf-8') - service.clearLastWrittenAuthJson('account-2') - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1RefreshedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account1RefreshedAuth) - }) - it('restores system default when unverified runtime auth appears before deselect', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system"}\n', 'utf-8') @@ -5795,44 +3893,6 @@ describe('CodexRuntimeHomeService', () => { ).toBe(true) }) - it('restores system default after same-identity managed Codex refresh on deselect', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') - const managedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'managed') - const externalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'external') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', managedAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ] - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - settings.activeCodexManagedAccountId = 'account-1' - service.syncForCurrentSelection() - - writeFileSync(runtimeAuthPath, externalAuth, 'utf-8') - settings.activeCodexManagedAccountId = null - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(externalAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-old"}\n') - }) - it('restores system default when stale Codex credentials are rejected on deselect', async () => { const runtimeAuthPath = getRuntimeCodexAuthPath() writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') @@ -5907,78 +3967,19 @@ describe('CodexRuntimeHomeService', () => { expect(existsSync(runtimeAuthPath)).toBe(false) }) - it('keeps external system-default logout when managed runtime auth still exists', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(getSystemCodexAuthPath(), '{"account":"system-old"}\n', 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - '{"account":"managed"}\n' - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"managed"}\n') - rmSync(getSystemCodexAuthPath(), { force: true }) - settings.activeCodexManagedAccountId = null - service.syncForCurrentSelection() - - expect(existsSync(runtimeAuthPath)).toBe(false) - }) - - it('recreates retained auth after switching from managed to logged-out system default', async () => { + it('recreates retained auth after a logged-out system default logs back in', async () => { const systemAuth = createCodexAuthJson('system@example.com', 'acct-system', 'old-token') const reloginAuth = createCodexAuthJson('system@example.com', 'acct-system', 'relogin-token') writeFileSync(getSystemCodexAuthPath(), systemAuth, 'utf-8') - const managedHomePath = createManagedAuth( - testState.userDataDir, - 'account-1', - createCodexAuthJson('managed@example.com', 'acct-managed', 'managed-token') - ) - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'managed@example.com', - managedHomePath, - providerAccountId: 'acct-managed', - workspaceLabel: null, - workspaceAccountId: 'acct-managed', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) + const store = createStore(createSettings()) const { CodexRuntimeHomeService } = await import('./runtime-home-service') const service = new CodexRuntimeHomeService(store as never) rmSync(getSystemCodexAuthPath()) - settings.activeCodexManagedAccountId = null service.syncForCurrentSelection() expect(existsSync(getRuntimeCodexAuthPath())).toBe(false) - setRealHomeLaneForTest(true) + setShellStartupEnvProbeSupportedForTest(true) service.setRealHomeLaneGate(() => true) writeFileSync(getSystemCodexAuthPath(), reloginAuth, 'utf-8') service.reconcileLegacySharedHomeForRetainedPanes() @@ -6027,95 +4028,6 @@ describe('CodexRuntimeHomeService', () => { expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe('{"account":"system-2"}\n') }) - it('reads back refreshed tokens for the outgoing Codex account before switching', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const account1Original = createCodexAuthJson('one@example.com', 'acct-1', 'one-original') - const account1Refreshed = createCodexAuthJson('one@example.com', 'acct-1', 'one-refreshed') - const account2Auth = createCodexAuthJson('two@example.com', 'acct-2', 'two') - const managedHomePath1 = createManagedAuth(testState.userDataDir, 'account-1', account1Original) - const managedHomePath2 = createManagedAuth(testState.userDataDir, 'account-2', account2Auth) - const managedAuthPath1 = join(managedHomePath1, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'one@example.com', - managedHomePath: managedHomePath1, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - }, - { - id: 'account-2', - email: 'two@example.com', - managedHomePath: managedHomePath2, - providerAccountId: 'acct-2', - workspaceLabel: null, - workspaceAccountId: 'acct-2', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - writeFileSync(runtimeAuthPath, account1Refreshed, 'utf-8') - settings.activeCodexManagedAccountId = 'account-2' - service.syncForCurrentSelection() - - expect(readFileSync(managedAuthPath1, 'utf-8')).toBe(account1Refreshed) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(account2Auth) - }) - - it('does not clobber fresh tokens after clearLastWrittenAuthJson', async () => { - const runtimeAuthPath = getRuntimeCodexAuthPath() - writeFileSync(runtimeAuthPath, '{"account":"system"}\n', 'utf-8') - const originalAuth = createCodexAuthJson('user@example.com', 'acct-1', 'original') - const reauthedAuth = createCodexAuthJson('user@example.com', 'acct-1', 'reauthed') - const managedHomePath = createManagedAuth(testState.userDataDir, 'account-1', originalAuth) - const managedAuthPath = join(managedHomePath, 'auth.json') - const settings = createSettings({ - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: 'acct-1', - workspaceLabel: null, - workspaceAccountId: 'acct-1', - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ], - activeCodexManagedAccountId: 'account-1' - }) - const store = createStore(settings) - - const { CodexRuntimeHomeService } = await import('./runtime-home-service') - const service = new CodexRuntimeHomeService(store as never) - - // Simulate re-auth: managed storage gets fresh tokens - writeFileSync(managedAuthPath, reauthedAuth, 'utf-8') - - // Clear tracking before sync (as CodexAccountService would) - service.clearLastWrittenAuthJson() - service.syncForCurrentSelection() - - // Fresh re-auth tokens should survive — not be clobbered by stale runtime read-back - expect(readFileSync(managedAuthPath, 'utf-8')).toBe(reauthedAuth) - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(reauthedAuth) - }) - it('preserves conflicting legacy session files under deterministic names', async () => { const runtimeSessionsDir = join(getRuntimeCodexHomePath(), 'sessions') mkdirSync(runtimeSessionsDir, { recursive: true }) diff --git a/src/main/codex-accounts/runtime-home-service.ts b/src/main/codex-accounts/runtime-home-service.ts index fa2f27798b6..c2b99b52c6d 100644 --- a/src/main/codex-accounts/runtime-home-service.ts +++ b/src/main/codex-accounts/runtime-home-service.ts @@ -67,7 +67,6 @@ import { type CodexAccountSelectionTarget } from './runtime-selection' import { getDefaultWslDistro, getWslHome } from '../wsl' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { hasCustomCodexHomeOverrideForLaunch } from '../codex/codex-real-home-path' import { invalidateCodexSessionBackfillMarker } from '../codex/codex-session-backfill-marker' import { assertOwnedHostCodexManagedHomePath } from './host-codex-managed-home-ownership' @@ -176,8 +175,8 @@ export class CodexRuntimeHomeService { private readonly lastSyncedWslAccountIdByDistro = new Map() private readonly wslRuntimeHomePathByDistro = new Map() private skipNextReadBackForAccountId: string | null = null - // Why: a flag-ON host account refreshes auth in its own home. Remember that - // provenance so a later deselect/rollback never adopts stale shared bytes. + // Why: a managed host account refreshes auth in its own home. Remember that + // provenance so a later deselect never adopts stale shared bytes. private lastHostAccountUsedSelfContainedHome = false private sharedAuthRefreshBlockedByManagedTransition = false // Why: transient auth.json read/parse failures must not deselect an account. @@ -236,7 +235,7 @@ export class CodexRuntimeHomeService { // system default without injecting a path Orca cannot prove it owns. } if (this.isHostSystemDefaultRealHome(launchEnv)) { - // Why (flag ON, system default): run Codex on the user's own ~/.codex. + // Why: the system default runs Codex on the user's own ~/.codex. // Returning null tells the PTY/env layer to inject no managed CODEX_HOME; // the retired mirror is refreshed only for pre-rollout PTYs. this.reconcileLegacySharedHomeForRetainedPanes() @@ -254,17 +253,12 @@ export class CodexRuntimeHomeService { return this.getRuntimeHomePath() } - // Why: with the real-home flag ON, a managed HOST account runs against its own - // self-contained CODEX_HOME (codex-accounts//home) instead of the shared - // runtime mirror. Its auth.json lives there and codex refreshes it in place, - // so two accounts never race one auth.json (GAP-5) and the mirror can be - // deleted once no lane still injects it (GAP-1). WSL accounts keep their - // per-distro lane; the flag-OFF opt-out keeps the shared-home hot-swap. + // Why: a managed HOST account runs against its own self-contained CODEX_HOME + // (codex-accounts//home) rather than the shared runtime mirror. Its + // auth.json lives there and codex refreshes it in place, so two accounts never + // race one auth.json. WSL accounts keep their per-distro lane. private getSelfContainedManagedHostAccount(): CodexManagedAccount | null { const settings = this.store.getSettings() - if (!isCodexSystemDefaultRealHomeEnabled()) { - return null - } const account = this.getActiveAccount( settings.codexManagedAccounts, normalizeCodexRuntimeSelection(settings).host @@ -276,22 +270,17 @@ export class CodexRuntimeHomeService { } // Why: session discovery must surface a managed account's own rollouts wherever - // they physically live. Flag ON makes every host managed home a live CODEX_HOME, - // so scan them all. Flag OFF (opt-out/rollback) hands launches back to the shared - // mirror, but a home that already accumulated rollouts while the flag was ON must - // still surface them — otherwise opting out silently hides history that is safe on - // disk. Gate the flag-OFF case on a sessions/ tree so a never-enabled install stays - // byte-identical to today (its per-account homes hold only auth, no rollouts). + // they physically live. Every host managed home is a live CODEX_HOME, so scan + // them all. private getManagedHostAccountHomesForSessionDiscovery(): string[] { const settings = this.store.getSettings() - const flagEnabled = isCodexSystemDefaultRealHomeEnabled() const homes: string[] = [] for (const account of settings.codexManagedAccounts) { if (this.getWslManagedHomePath(account)) { continue } const trustedHome = this.getTrustedSelfContainedManagedHomePath(account) - if (trustedHome && (flagEnabled || existsSync(join(trustedHome, 'sessions')))) { + if (trustedHome) { homes.push(trustedHome) } } @@ -420,8 +409,9 @@ export class CodexRuntimeHomeService { if (normalizeCodexRuntimeSelection(settings).host !== null) { return } - const realHomeSelected = this.isHostSystemDefaultRealHomeSelected(launchEnv) - if (realHomeSelected || !isCodexSystemDefaultRealHomeEnabled()) { + // Why: reached only when the real-home lane is selected but its gate is off, + // so the launch runs on the mirror and the backfill marker is stale. + if (this.isHostSystemDefaultRealHomeSelected(launchEnv)) { invalidateCodexSessionBackfillMarker( join(getCodexSessionBackfillStateDirPath(), 'backfill-complete.json') ) @@ -463,10 +453,9 @@ export class CodexRuntimeHomeService { // mirror, so include the real root for both directly-routed host lanes. homes.push(getSystemCodexHomePath()) } - // Why: flag ON routes each managed host account to its own self-contained - // home, so its rollouts live there rather than in the shared mirror. Scan - // every such home — plus any that retained rollouts across an opt-out — so - // account-scoped sessions still surface in the AI Vault. + // Why: each managed host account runs in its own self-contained home, so + // its rollouts live there rather than in the shared mirror. Scan every such + // home so account-scoped sessions still surface in the AI Vault. for (const perAccountHome of this.getManagedHostAccountHomesForSessionDiscovery()) { homes.push(perAccountHome) } @@ -475,9 +464,8 @@ export class CodexRuntimeHomeService { /** * The account-owned CODEX_HOME the current HOST selection runs against, or - * null when the selection is not routed to one (system default, or the - * flag-OFF shared mirror, which every account hot-swaps and so names no - * account). + * null when the selection is not routed to one (system default, or a WSL + * account, whose home lives inside the distro). * * Read-only on purpose: session discovery ranks homes with this before any * launch prep, so it must create no directories and sync no auth. @@ -505,13 +493,13 @@ export class CodexRuntimeHomeService { this.realHomeLaneGate = gate } - // Why: real-home routing applies only to the host system-default selection - // with the staged flag ON. Managed accounts keep hot-swap isolation; custom - // CODEX_HOMEs stay managed until phase 1 can track cleanup across old homes. + // Why: real-home routing applies only to the host system-default selection. + // Managed accounts run in their own homes; Windows (no shell-startup probe) + // and custom CODEX_HOMEs stay on the mirror until cleanup can be tracked + // across old homes. isHostSystemDefaultRealHomeSelected(launchEnv?: NodeJS.ProcessEnv): boolean { const settings = this.store.getSettings() if ( - !isCodexSystemDefaultRealHomeEnabled() || normalizeCodexRuntimeSelection(settings).host !== null || !isShellStartupEnvProbeSupported() ) { @@ -640,9 +628,9 @@ export class CodexRuntimeHomeService { } const settings = this.store.getSettings() if (this.lastHostAccountUsedSelfContainedHome) { - // Why: E auth is already canonical in the per-account home. Reset the - // legacy mirror baseline without reading it; flag-OFF can then seed the - // mirror from canonical storage, while real-home deselect needs no sync. + // Why: the account's auth is already canonical in its own home. Reset the + // legacy mirror baseline without reading it; a real-home deselect needs no + // further sync, and the mirror lane below re-seeds from canonical storage. this.lastHostAccountUsedSelfContainedHome = false this.lastSyncedAccountId = null this.lastWrittenAuthJson = null @@ -668,105 +656,16 @@ export class CodexRuntimeHomeService { settings.codexManagedAccounts, normalizeCodexRuntimeSelection(settings).host ) - const previousAccount = this.getActiveAccount( - settings.codexManagedAccounts, - this.lastSyncedAccountId - ) - if (this.getWslManagedHomePath(activeAccount)) { - const previousWasHostManaged = previousAccount && !this.getWslManagedHomePath(previousAccount) - const outgoingReadBackResult = previousWasHostManaged - ? this.readBackRefreshedTokensForAccount(previousAccount, { - updateLastWrittenAuthJson: false - }) - : 'unchanged' - if (previousWasHostManaged) { - this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult !== 'rejected' - }) - } + if (activeAccount) { + // Why: only a WSL-managed account can reach here — every host account was + // routed to its own self-contained home above. Its auth lives in the + // distro-local runtime home, so the host mirror only drops its baseline. this.lastSyncedAccountId = null this.lastWrittenAuthJson = null this.skipNextReadBackForAccountId = null return } - let outgoingReadBackResult: CodexReadBackResult = 'unchanged' - if (previousAccount && previousAccount.id !== activeAccount?.id) { - outgoingReadBackResult = this.readBackRefreshedTokensForAccount(previousAccount, { - updateLastWrittenAuthJson: true - }) - } - if (!activeAccount) { - if (normalizeCodexRuntimeSelection(settings).host) { - this.store.updateSettings({ - activeCodexManagedAccountId: null, - activeCodexManagedAccountIdsByRuntime: { - ...normalizeCodexRuntimeSelection(settings), - host: null - } - }) - } - // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult !== 'rejected' - }) - this.lastSyncedAccountId = null - } else if (!runtimeAuthExistedBeforeSync) { - const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() - if (logoutMarkerStatus.kind === 'applies') { - this.lastWrittenAuthJson = null - } else if ( - logoutMarkerStatus.kind === 'system-default-changed' && - logoutMarkerStatus.systemDefaultAuthJson !== null - ) { - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else if (logoutMarkerStatus.kind === 'system-default-changed') { - // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. - this.captureSystemDefaultSnapshot({ force: true }) - this.persistRuntimeLogoutMarker(null) - this.lastWrittenAuthJson = null - } else if (this.lastWrittenAuthJson === null) { - // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. - this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) - } else { - this.persistRuntimeLogoutMarker() - } - } else { - this.clearRuntimeLogoutMarker() - this.syncRuntimeAuthWithSystemDefault() - } - return - } - - const activeAuthPath = join(activeAccount.managedHomePath, 'auth.json') - const authAbsence = this.credentialAbsenceGrace.assess(activeAuthPath) - if (authAbsence.state !== 'present' && authAbsence.state !== 'incomplete') { - if (!authAbsence.durable) { - if (this.sharedRuntimeAuthBelongsToAccount(activeAccount)) { - // Why: mid-rotation reads look missing/unreadable for a moment; skip - // this sync without deselecting and let a settled read decide later. - console.warn( - '[codex-runtime-home] Active managed account auth.json unavailable, keeping selection through grace window' - ) - return - } - // Why: the runtime home still holds another account, so riding out the - // grace would launch that account under this selection. Not being able - // to read the selected account is no license to run a different one. - console.warn( - '[codex-runtime-home] Active managed account auth.json unavailable while the runtime home holds another account, clearing runtime auth' - ) - this.clearRuntimeAuthForUnprovenSelection() - return - } - console.warn( - '[codex-runtime-home] Active managed account credential is unavailable, restoring system default' - ) - // Why: valid credential-free JSON is an explicit logout; never revive it - // from stale shared-home bytes while clearing the selection. - if (authAbsence.state !== 'no-credential' && this.lastSyncedAccountId === activeAccount.id) { - outgoingReadBackResult = this.recoverRefreshForMissingActiveAccount(activeAccount) - } + if (normalizeCodexRuntimeSelection(settings).host) { this.store.updateSettings({ activeCodexManagedAccountId: null, activeCodexManagedAccountIdsByRuntime: { @@ -774,38 +673,35 @@ export class CodexRuntimeHomeService { host: null } }) - if (this.lastSyncedAccountId !== null) { - this.restoreSystemDefaultSnapshot({ - detectExternalLogin: outgoingReadBackResult !== 'rejected' - }) - this.lastSyncedAccountId = null - } - return } - - if (this.lastSyncedAccountId === null) { - this.captureSystemDefaultSnapshot({ force: true }) - } - - // Why: Codex refreshes OAuth tokens in the runtime auth.json; if it differs from Orca's last write, read those back to managed storage before overwriting. - if (this.lastSyncedAccountId === activeAccount.id) { - if (this.skipNextReadBackForAccountId === activeAccount.id) { - this.skipNextReadBackForAccountId = null + // Why: only restore the system-default mirror when leaving a managed account; otherwise later syncs mirror current ~/.codex instead of replaying an old snapshot. + if (this.lastSyncedAccountId !== null) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: true }) + this.lastSyncedAccountId = null + } else if (!runtimeAuthExistedBeforeSync) { + const logoutMarkerStatus = this.getRuntimeLogoutMarkerStatus() + if (logoutMarkerStatus.kind === 'applies') { + this.lastWrittenAuthJson = null + } else if ( + logoutMarkerStatus.kind === 'system-default-changed' && + logoutMarkerStatus.systemDefaultAuthJson !== null + ) { + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) + } else if (logoutMarkerStatus.kind === 'system-default-changed') { + // Why: a real ~/.codex logout after a local runtime logout should keep runtime auth absent, not restore the stale snapshot. + this.captureSystemDefaultSnapshot({ force: true }) + this.persistRuntimeLogoutMarker(null) + this.lastWrittenAuthJson = null + } else if (this.lastWrittenAuthJson === null) { + // Why: unmanaged sessions use an Orca-owned CODEX_HOME; seed it once from system-default auth so terminals stay logged in without mutating ~/.codex. + this.restoreSystemDefaultSnapshot({ detectExternalLogin: false }) } else { - this.readBackRefreshedTokens({ - updateLastWrittenAuthJson: true - }) + this.persistRuntimeLogoutMarker() } + } else { + this.clearRuntimeLogoutMarker() + this.syncRuntimeAuthWithSystemDefault() } - - if (this.lastSyncedAccountId !== activeAccount.id) { - this.skipNextReadBackForAccountId = null - } - this.lastSyncedAccountId = activeAccount.id - this.writeRuntimeAuth(readFileSync(activeAuthPath, 'utf-8'), { - owner: 'managed', - accountId: activeAccount.id - }) } // Why: re-auth/add-account write fresh managed tokens, so skip the next read-back to avoid clobbering them with stale runtime tokens. @@ -818,26 +714,6 @@ export class CodexRuntimeHomeService { this.skipNextReadBackForAccountId = accountId } - private readBackRefreshedTokens(options: { - updateLastWrittenAuthJson: boolean - }): CodexReadBackResult { - const selectedAccountId = normalizeCodexRuntimeSelection(this.store.getSettings()).host - if (selectedAccountId) { - const selectedAccountResult = this.readBackRefreshedTokensFromPath( - this.getRuntimeAuthPath(), - { - ...options, - expectedAccountId: selectedAccountId - } - ) - if (selectedAccountResult !== 'rejected') { - return selectedAccountResult - } - } - - return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), options) - } - private readBackRefreshedTokensFromPath( runtimeAuthPath: string, options: { @@ -895,119 +771,6 @@ export class CodexRuntimeHomeService { } } - private readBackRefreshedTokensForAccount( - account: CodexManagedAccount, - options: { updateLastWrittenAuthJson: boolean } - ): CodexReadBackResult { - return this.readBackRefreshedTokensFromPath(this.getRuntimeAuthPath(), { - ...options, - expectedAccountId: account.id - }) - } - - private recoverRefreshForMissingActiveAccount(account: CodexManagedAccount): CodexReadBackResult { - try { - const runtimeAuthPath = this.getRuntimeAuthPath() - if (!existsSync(runtimeAuthPath) || this.lastWrittenAuthJson === null) { - return 'rejected' - } - const runtimeContents = readFileSync(runtimeAuthPath, 'utf-8') - if (runtimeContents === this.lastWrittenAuthJson) { - return 'unchanged' - } - // Why: the canonical file is gone, so the exact in-memory bytes Orca - // previously mirrored are the only safe identity baseline for recovery. - if (!codexAuthMatchesManagedAccount(runtimeContents, account, this.lastWrittenAuthJson)) { - return 'rejected' - } - writeFileAtomically(join(account.managedHomePath, 'auth.json'), runtimeContents, { - mode: 0o600 - }) - this.lastWrittenAuthJson = runtimeContents - return 'persisted' - } catch (error) { - console.warn('[codex-runtime-home] Failed to recover missing managed auth:', error) - return 'rejected' - } - } - - // Why: stale panes can overwrite the mirror after provenance is committed, so - // launch ownership needs current-byte identity or Orca's exact same-run write. - private sharedRuntimeAuthBelongsToAccount(account: CodexManagedAccount): boolean { - if (!existsSync(this.getRuntimeAuthPath())) { - return true - } - const runtimeAuth = this.readRuntimeAuthForProvenance() - if (runtimeAuth !== null) { - if (codexAuthMatchesManagedAccount(runtimeAuth, account, null)) { - return true - } - // Why: Orca itself mirrored these exact bytes for this account this run. - if (this.lastSyncedAccountId === account.id && this.lastWrittenAuthJson === runtimeAuth) { - return true - } - } - return false - } - - // Why: the absence may still heal, so keep the selection — but leave no other - // identity's credentials behind for the launch. Logged out beats logged in as - // someone else, and fencing stops later syncs adopting the removed bytes. - private clearRuntimeAuthForUnprovenSelection(): void { - const runtimeAuthPath = this.getRuntimeAuthPath() - try { - // Why: a refresh Codex wrote into the mirror belongs to whoever owns - // those bytes; persist it to that owner's home — managed or ~/.codex — - // first, then fence so later syncs cannot adopt the removed bytes. - const readBackResult = this.readBackRefreshedTokensFromPath(runtimeAuthPath, { - updateLastWrittenAuthJson: false - }) - if (readBackResult === 'rejected') { - this.readBackRefreshedSystemDefaultAuth() - } - this.persistSharedRuntimeAuthProvenance({ owner: 'fenced' }) - } catch (error) { - // Why: rescue and metadata are best-effort; neither may leave another - // identity's credentials in the home returned to the launch. - console.warn('[codex-runtime-home] Failed to rescue or fence unproven runtime auth:', error) - } - this.lastWrittenAuthJson = null - try { - rmSync(runtimeAuthPath, { force: true }) - } catch (error) { - // Why: a fence is Orca metadata that Codex does not read. If Windows or - // another host keeps auth.json locked, failing launch is the safe result. - throw new Error('Cannot safely launch Codex while stale runtime auth remains.', { - cause: error - }) - } - } - - // Why: a token Codex refreshed inside the mirror has no managed home to fall - // back to when the mirror holds the user's own ~/.codex credential, so persist - // it there before the unproven mirror is dropped. - private readBackRefreshedSystemDefaultAuth(): void { - const runtimeAuth = this.readRuntimeAuthForProvenance() - const systemDefaultAuth = this.readSystemDefaultAuth() - if (runtimeAuth === null || systemDefaultAuth === null || runtimeAuth === systemDefaultAuth) { - return - } - const claim = this.resolveSystemDefaultMirrorClaim( - runtimeAuth, - this.resolveSharedRuntimeAuthProvenanceStatus() - ) - if ( - !claim.ownershipProven || - claim.mirroredAuthJson === null || - systemDefaultAuth !== claim.mirroredAuthJson || - !this.runtimeAuthMatchesSystemDefaultIdentity(runtimeAuth, systemDefaultAuth) - ) { - return - } - this.writeSystemDefaultAuth(runtimeAuth) - this.captureSystemDefaultSnapshot({ force: true }) - } - // Why: which ~/.codex bytes the mirror was seeded from, and whether the system // default can be proven to own the mirror at all. private resolveSystemDefaultMirrorClaim( @@ -1399,9 +1162,6 @@ export class CodexRuntimeHomeService { private safeMigrateLegacySharedAuth(): void { const settings = this.store.getSettings() - if (!isCodexSystemDefaultRealHomeEnabled()) { - return - } try { migrateLegacySharedAuthToPerAccountHome({ activeHostAccountId: normalizeCodexRuntimeSelection(settings).host, diff --git a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts index a1a66a847d8..54ab5d5f095 100644 --- a/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts +++ b/src/main/codex-accounts/runtime-home-windows-profile-ownership.test.ts @@ -3,7 +3,6 @@ import type { GlobalSettings } from '../../shared/types' import { CodexRuntimeHomeService } from './runtime-home-service' const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform') -const originalRealHomeOverride = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME const originalCodexHome = process.env.CODEX_HOME const originalOrcaCodexHome = process.env.ORCA_CODEX_HOME @@ -11,7 +10,6 @@ afterEach(() => { if (originalPlatform) { Object.defineProperty(process, 'platform', originalPlatform) } - restoreEnv('ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', originalRealHomeOverride) restoreEnv('CODEX_HOME', originalCodexHome) restoreEnv('ORCA_CODEX_HOME', originalOrcaCodexHome) }) @@ -19,7 +17,6 @@ afterEach(() => { describe('Windows System Default Codex home ownership', () => { it('stays managed when PowerShell profile state cannot be inspected', () => { Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = '1' delete process.env.CODEX_HOME delete process.env.ORCA_CODEX_HOME diff --git a/src/main/codex-accounts/service.test.ts b/src/main/codex-accounts/service.test.ts index 175a42e6dbd..47fd0c67850 100644 --- a/src/main/codex-accounts/service.test.ts +++ b/src/main/codex-accounts/service.test.ts @@ -46,33 +46,10 @@ function decodeEncodedWslBashCommand(command: string): string { return encoded ? Buffer.from(encoded, 'base64').toString('utf8') : command } -// Why: the shipped code no longer reads a settings flag — the legacy mirror -// lane is reachable only through the test-rig env override. Route the old -// per-test override key to that env var so lane coverage keeps working. -type TestSettingsOverrides = Partial & { - codexSystemDefaultRealHomeEnabled?: boolean -} - -function setRealHomeLaneForTest(enabled: boolean): void { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = enabled ? '1' : '0' -} - -const initialRealHomeLaneEnv = process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME -afterEach(() => { - if (initialRealHomeLaneEnv === undefined) { - delete process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME - } else { - process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME = initialRealHomeLaneEnv - } -}) - -function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings { +function createSettings(overrides: Partial = {}): GlobalSettings { const appFontFamily = overrides.appFontFamily ?? 'Geist' const agentStatusHooksEnabled = overrides.agentStatusHooksEnabled ?? true const tabAutoGenerateTitle = overrides.tabAutoGenerateTitle ?? false - // Config-sync/hot-swap tests assert the shared-mirror path; production is - // real-home always, so opt these managed cases out unless a test overrides it. - setRealHomeLaneForTest(overrides.codexSystemDefaultRealHomeEnabled ?? false) return { workspaceDir: testState.fakeHomeDir, nestWorkspaces: false, @@ -430,7 +407,6 @@ describe('CodexAccountService config sync', () => { 'approval_policy = "on-request"\n' ) const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: true, codexManagedAccounts: [ { id: 'account-1', @@ -476,43 +452,6 @@ describe('CodexAccountService config sync', () => { expectSanitizedManagedConfig() }) - it('keeps flag-off config mirroring byte-identical', async () => { - const fixture = await createCanonicalHookTrustFixture() - const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') - writeFileSync(canonicalConfigPath, fixture.config, 'utf-8') - const managedHomePath = createManagedHome( - testState.userDataDir, - 'account-1', - 'approval_policy = "on-request"\n' - ) - const settings = createSettings({ - codexSystemDefaultRealHomeEnabled: false, - codexManagedAccounts: [ - { - id: 'account-1', - email: 'user@example.com', - managedHomePath, - providerAccountId: null, - workspaceLabel: null, - workspaceAccountId: null, - createdAt: 1, - updatedAt: 1, - lastAuthenticatedAt: 1 - } - ] - }) - - const { CodexAccountService } = await import('./service') - new CodexAccountService( - createStore(settings) as never, - createRateLimits() as never, - createRuntimeHome() as never - ) - - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(fixture.config) - expect(readFileSync(canonicalConfigPath, 'utf-8')).toBe(fixture.config) - }) - it('rewrites relative path config values when syncing into managed homes', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') writeFileSync( @@ -556,7 +495,7 @@ describe('CodexAccountService config sync', () => { expect(managedConfig).toContain('sandbox_mode = "danger-full-access"') }) - it('does not rewrite managed configs that already match canonical config', async () => { + it('does not rewrite a managed config the previous mirror pass already settled', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') const { escapeTomlString } = await import('../codex/config-toml-trust') const userHookKey = `${join(testState.fakeHomeDir, '.codex', 'user-hooks.json')}:stop:0:0` @@ -575,8 +514,6 @@ describe('CodexAccountService config sync', () => { '{"account":"managed"}\n' ) const managedConfigPath = join(managedHomePath, 'config.toml') - const oldDate = new Date('2024-01-01T00:00:00.000Z') - utimesSync(managedConfigPath, oldDate, oldDate) const settings = createSettings({ codexManagedAccounts: [ { @@ -600,6 +537,15 @@ describe('CodexAccountService config sync', () => { const { CodexAccountService } = await import('./service') new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) + // The first pass remaps the user hook-trust entry into this home; once that + // has settled, a later pass must leave the file completely untouched. + const settledConfig = readFileSync(managedConfigPath, 'utf-8') + const oldDate = new Date('2024-01-01T00:00:00.000Z') + utimesSync(managedConfigPath, oldDate, oldDate) + + new CodexAccountService(store as never, rateLimits as never, runtimeHome as never) + + expect(readFileSync(managedConfigPath, 'utf-8')).toBe(settledConfig) expect(statSync(managedConfigPath).mtimeMs).toBeLessThan(Date.now() - 60_000) }) @@ -659,8 +605,7 @@ describe('CodexAccountService config sync', () => { it('re-syncs config when selecting an account', async () => { const canonicalConfigPath = join(testState.fakeHomeDir, '.codex', 'config.toml') - const canonicalConfig = 'approval_policy = "never"\nsandbox_mode = "danger-full-access"\n' - writeFileSync(canonicalConfigPath, canonicalConfig, 'utf-8') + writeFileSync(canonicalConfigPath, 'sandbox_mode = "danger-full-access"\n', 'utf-8') const managedHomePath = createManagedHome( testState.userDataDir, 'account-1', @@ -696,7 +641,11 @@ describe('CodexAccountService config sync', () => { await service.selectAccount('account-1') - expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe(canonicalConfig) + // Selecting merges canonical settings into the account's own home rather + // than overwriting it, so its local approval_policy survives the re-sync. + expect(readFileSync(join(managedHomePath, 'config.toml'), 'utf-8')).toBe( + 'sandbox_mode = "danger-full-access"\napproval_policy = "untrusted"\n' + ) expect(rateLimits.refreshForCodexAccountChange).toHaveBeenCalledTimes(1) expect(runtimeHome.syncForCurrentSelection).toHaveBeenCalledTimes(1) }) @@ -855,7 +804,7 @@ describe('CodexAccountService config sync', () => { readHookTrustEntries = (await import('../codex/config-toml-trust')).readHookTrustEntries writeFileSync(join(testState.fakeHomeDir, '.codex', 'config.toml'), fixture.config, 'utf-8') - const store = createStore(createSettings({ codexSystemDefaultRealHomeEnabled: true })) + const store = createStore(createSettings()) const rateLimits = createRateLimits() const runtimeHome = createRuntimeHome() const { CodexAccountService } = await import('./service') @@ -1842,7 +1791,7 @@ describe('CodexAccountService config sync', () => { expect(onHostSystemDefaultSelected).toHaveBeenCalledOnce() }) - it('selectAccount immediately rewrites the shared runtime auth for existing terminals', async () => { + it('selectAccount switches managed accounts without routing auth through the shared mirror', async () => { const firstAuth = createCodexAuthJson('one@example.com', 'acct-one', 'one') const secondAuth = createCodexAuthJson('two@example.com', 'acct-two', 'two') const firstManagedHomePath = createManagedHome( @@ -1890,7 +1839,7 @@ describe('CodexAccountService config sync', () => { const { CodexRuntimeHomeService } = await import('./runtime-home-service') const runtimeHome = new CodexRuntimeHomeService(store as never) const runtimeAuthPath = join(testState.userDataDir, 'codex-runtime-home', 'home', 'auth.json') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(firstAuth) + expect(existsSync(runtimeAuthPath)).toBe(false) const { CodexAccountService } = await import('./service') const service = new CodexAccountService( @@ -1901,7 +1850,11 @@ describe('CodexAccountService config sync', () => { await service.selectAccount('account-2') - expect(readFileSync(runtimeAuthPath, 'utf-8')).toBe(secondAuth) + // Each managed host account launches against its own home, so a switch must + // leave both credential files alone and never copy either into the mirror. + expect(existsSync(runtimeAuthPath)).toBe(false) + expect(readFileSync(join(firstManagedHomePath, 'auth.json'), 'utf-8')).toBe(firstAuth) + expect(readFileSync(join(secondManagedHomePath, 'auth.json'), 'utf-8')).toBe(secondAuth) expect(existsSync(join(testState.userDataDir, 'codex-runtime-home', 'launch'))).toBe(false) expect(existsSync(join(testState.userDataDir, 'codex-runtime-home', 'active'))).toBe(false) }) diff --git a/src/main/codex-accounts/service.ts b/src/main/codex-accounts/service.ts index 3618f138144..b55c9b50315 100644 --- a/src/main/codex-accounts/service.ts +++ b/src/main/codex-accounts/service.ts @@ -30,7 +30,6 @@ import type { CodexRuntimeHomeService } from './runtime-home-service' import { writeFileAtomically } from './fs-utils' import { rewriteRelativePathConfigValues } from '../codex/codex-config-path-reference-rewrite' import { stripCodexManagedHookTrustEntriesFromConfig } from '../codex/codex-managed-trust-reconciliation' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { getCodexManagedHookInstallMaterial } from '../codex/hook-service' import { syncSystemConfigIntoManagedCodexHome } from '../codex/codex-config-mirror' import { getSystemCodexHomePath } from '../codex/codex-home-paths' @@ -1238,9 +1237,9 @@ export class CodexAccountService { } private isSelfContainedHostManagedHome(managedHomePath: string): boolean { - // Why: flag ON makes each host account home its own launch CODEX_HOME. WSL - // homes keep their distro-local seed lane; the flag-OFF opt-out is unchanged. - return isCodexSystemDefaultRealHomeEnabled() && !parseWslUncPath(managedHomePath) + // Why: each host account home is its own launch CODEX_HOME. WSL homes keep + // their distro-local seed lane. + return !parseWslUncPath(managedHomePath) } private syncCanonicalConfigIntoManagedHome( @@ -1270,18 +1269,15 @@ export class CodexAccountService { // account while preserving consistent Codex behavior. Managed homes are // real CODEX_HOMEs for `codex login`, so relative path-valued settings // must keep resolving against the home the config was read from. - let sanitizedConfig = canonicalConfig.contents - if (isCodexSystemDefaultRealHomeEnabled()) { - const material = getCodexManagedHookInstallMaterial() - // Why: source-home Orca trust is foreign to each managed home's hooks.json. - sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { - runtimeHomePath: canonicalConfig.sourceHomePath, - sourcePath: canonicalConfig.sourceHooksPath, - command: material.command, - managedEventLabels: new Set(Object.values(material.eventLabel)), - timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS - }) - } + const material = getCodexManagedHookInstallMaterial() + // Why: source-home Orca trust is foreign to each managed home's hooks.json. + const sanitizedConfig = stripCodexManagedHookTrustEntriesFromConfig(canonicalConfig.contents, { + runtimeHomePath: canonicalConfig.sourceHomePath, + sourcePath: canonicalConfig.sourceHooksPath, + command: material.command, + managedEventLabels: new Set(Object.values(material.eventLabel)), + timeoutSec: MANAGED_HOOK_TIMEOUT_SECONDS + }) this.writeManagedConfig( trustedManagedHomePath, rewriteRelativePathConfigValues(sanitizedConfig, canonicalConfig.sourceHomePath) diff --git a/src/main/codex/codex-real-home-flag.test.ts b/src/main/codex/codex-real-home-flag.test.ts deleted file mode 100644 index 63b6889a757..00000000000 --- a/src/main/codex/codex-real-home-flag.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { isCodexSystemDefaultRealHomeEnabled } from './codex-real-home-flag' - -const ENV_FLAG = 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME' -let previousEnvFlag: string | undefined - -beforeEach(() => { - previousEnvFlag = process.env[ENV_FLAG] - delete process.env[ENV_FLAG] -}) - -afterEach(() => { - if (previousEnvFlag === undefined) { - delete process.env[ENV_FLAG] - } else { - process.env[ENV_FLAG] = previousEnvFlag - } -}) - -describe('isCodexSystemDefaultRealHomeEnabled', () => { - it('is unconditionally ON in production (no settings consulted)', () => { - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(true) - }) - - it('lets the test-rig env override force ON explicitly', () => { - for (const raw of ['1', 'true', 'on', 'TRUE', ' On ']) { - process.env[ENV_FLAG] = raw - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(true) - } - }) - - it('lets the test-rig env override pin the legacy managed lane OFF', () => { - for (const raw of ['0', 'false', 'off']) { - process.env[ENV_FLAG] = raw - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(false) - } - }) - - it('ignores an unrecognized env value and stays ON', () => { - process.env[ENV_FLAG] = 'maybe' - expect(isCodexSystemDefaultRealHomeEnabled()).toBe(true) - }) -}) diff --git a/src/main/codex/codex-real-home-flag.ts b/src/main/codex/codex-real-home-flag.ts deleted file mode 100644 index 541dd98efa8..00000000000 --- a/src/main/codex/codex-real-home-flag.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Routing truth for the SYSTEM-DEFAULT Codex account: it always runs against - * the user's real ~/.codex; managed (multi-account) selections always get - * their own self-contained homes. There is no user-facing setting — the - * feature ships unconditionally. - * - * The env override exists only for test rigs (containment harness, e2e home - * isolation, CDP verification) that must pin the legacy managed-home lane or - * force the real-home lane inside a disposable HOME. It never appears in any - * UI and no production path sets it. - */ -const CODEX_REAL_HOME_ENV_FLAG = 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME' - -export function isCodexSystemDefaultRealHomeEnabled(): boolean { - const envOverride = readCodexRealHomeEnvOverride() - if (envOverride !== null) { - return envOverride - } - return true -} - -function readCodexRealHomeEnvOverride(): boolean | null { - const raw = process.env[CODEX_REAL_HOME_ENV_FLAG] - if (raw === undefined) { - return null - } - const normalized = raw.trim().toLowerCase() - if (normalized === '1' || normalized === 'true' || normalized === 'on') { - return true - } - if (normalized === '0' || normalized === 'false' || normalized === 'off') { - return false - } - return null -} diff --git a/src/main/grok/hook-service.test.ts b/src/main/grok/hook-service.test.ts index 02810726347..069b3b954be 100644 --- a/src/main/grok/hook-service.test.ts +++ b/src/main/grok/hook-service.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { spawn } from 'node:child_process' import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' @@ -16,12 +18,86 @@ vi.mock('os', async () => { }) import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service' +import { buildWindowsGrokHookScript } from './windows-grok-hook-script' import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract' const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' const WINDOWS_POWERSHELL_LAUNCHER = /^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/ +type WindowsGrokHookRun = { + status: number | null + stderr: string + stdout: string + request?: { path: string; body: string } +} + +function createWindowsGrokHookEnvironment(grokHome?: string): NodeJS.ProcessEnv { + const env = { ...process.env } as NodeJS.ProcessEnv + delete env.ORCA_AGENT_HOOK_ENDPOINT + if (grokHome === undefined) { + delete env.GROK_HOME + } else { + env.GROK_HOME = grokHome + } + env.ORCA_AGENT_HOOK_TOKEN = 'test-token' + env.ORCA_PANE_KEY = 'pane-test' + return env +} + +async function runWindowsGrokHook( + scriptPath: string, + env: NodeJS.ProcessEnv, + input: string +): Promise { + let request: WindowsGrokHookRun['request'] + const server = createServer((incoming, response) => { + let body = '' + incoming.setEncoding('utf8') + incoming.on('data', (chunk: string) => { + body += chunk + }) + incoming.on('end', () => { + request = { path: incoming.url ?? '', body } + response.writeHead(204).end() + }) + }) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.off('error', reject) + resolve() + }) + }) + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Could not resolve Windows Grok hook test listener port') + } + env.ORCA_AGENT_HOOK_PORT = String(address.port) + try { + const result = await new Promise>((resolve, reject) => { + const child = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', scriptPath], { + env, + stdio: ['pipe', 'pipe', 'pipe'] + }) + let stderr = '' + let stdout = '' + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk + }) + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + }) + child.once('error', reject) + child.once('close', (status) => resolve({ status, stderr, stdout })) + child.stdin.end(input) + }) + return { ...result, request } + } finally { + await new Promise((resolve) => server.close(() => resolve())) + } +} + describe('GrokHookService', () => { let homeDir: string @@ -35,6 +111,108 @@ describe('GrokHookService', () => { rmSync(homeDir, { recursive: true, force: true }) }) + // Why: #9358 / #9941 — empty GROK_HOME + parse-time %VAR:~n,m% / `"\"` broke + // every SessionStart/UserPromptSubmit on Windows outside Orca terminals. + it('guards Windows GROK_HOME substring checks when empty (#9358)', () => { + const script = buildWindowsGrokHookScript() + expect(script).toContain('set "ORCA_GROK_HOME="') + expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('%GROK_HOME:~4096,1%') + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') + expect(script).toContain(':orca_grok_home_ready') + expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"') + expect(script).toContain('if not "%GROK_HOME:~4096,1%"=="" goto :orca_grok_home_ready') + // Why: parenthesized `if defined (...)` still parse-expands the body early. + expect(script).not.toMatch(/if defined GROK_HOME \(/) + }) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 when GROK_HOME is unset (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-unset.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + // Why: delete GROK_HOME rather than set '' so cmd sees "not defined". + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(), + '{"hook_event_name":"SessionStart"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"SessionStart"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 with trailing-backslash GROK_HOME (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-slash.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const trailing = `${join(homeDir, 'grok-home-with-slash')}\\` + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(trailing), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe(`${trailing}.`) + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'generated grok-hook.cmd exits 0 with an oversized GROK_HOME (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-oversized.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(`C:\\${'a'.repeat(9000)}`), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch( + /syntax of the command is incorrect|命令语法不正确/i + ) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + + it.skipIf(process.platform !== 'win32')( + 'omits a max-length trailing-backslash GROK_HOME after safe normalization (#9358)', + async () => { + const scriptPath = join(homeDir, 'grok-hook-max-trailing.cmd') + writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8') + const trailingAtLimit = `C:\\${'a'.repeat(4092)}\\` + expect(trailingAtLimit).toHaveLength(4096) + const result = await runWindowsGrokHook( + scriptPath, + createWindowsGrokHookEnvironment(trailingAtLimit), + '{"hook_event_name":"UserPromptSubmit"}' + ) + expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0) + expect(result.request?.path).toBe('/hook/grok') + const form = new URLSearchParams(result.request?.body) + expect(form.get('grokHome')).toBe('') + expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}') + } + ) + it('installs a dedicated global Grok hook config and managed script', () => { const status = new GrokHookService().install() @@ -89,11 +267,12 @@ describe('GrokHookService', () => { expect(script).toContain('/hook/grok') if (process.platform === 'win32') { expect(script).toContain('%SystemRoot%\\System32\\curl.exe') - expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready') expect(script).toContain('%GROK_HOME:~4096,1%') - expect(script).toContain( - 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."' - ) + expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"') + expect(script).toContain('%ORCA_GROK_HOME:~4096,1%') + expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready') + expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"') expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"') } else { // Why: payload is piped to curl via stdin (`payload@-`) so it never lands diff --git a/src/main/grok/hook-service.ts b/src/main/grok/hook-service.ts index 564565d0d24..51a6150eab6 100644 --- a/src/main/grok/hook-service.ts +++ b/src/main/grok/hook-service.ts @@ -5,7 +5,6 @@ import { resolveGrokHomeDir } from '../../shared/grok-session-paths' import { buildManagedCommandHook, createManagedCommandMatcher, - buildWindowsAgentHookPostCommand, getSharedManagedScriptPath, readHooksJson, removeManagedCommands, @@ -20,19 +19,17 @@ import { writeHooksJsonRemote, writeManagedScriptRemote } from '../agent-hooks/installer-utils-remote' +import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract' import { - buildPosixHookPayloadCapture, - buildWindowsHookEnvironmentGuardLines, - buildWindowsHookStdinDrainEpilogue -} from '../agent-hooks/hook-stdin-contract' + buildWindowsGrokHookScript, + GROK_HOME_ENVELOPE_MAX_LENGTH +} from './windows-grok-hook-script' // Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare // `*` is not a valid "match all" pattern and can fail to load/match, so tool // lifecycle hooks never fire. `.*` matches every tool name (same as Command // Code's managed hooks). const GROK_TOOL_EVENT_MATCHER = '.*' -const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 -const WINDOWS_HOOK_PAYLOAD_FORM_LINE = ' --data-urlencode "payload@-" >nul 2>nul' const GROK_EVENTS = [ { eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } }, @@ -95,11 +92,6 @@ function hasControlCharacter(value: string): boolean { }) } -const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace( - WINDOWS_HOOK_PAYLOAD_FORM_LINE, - ` --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}` -) - function getManagedScriptFileName(): string { return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh' } @@ -116,21 +108,7 @@ function getManagedCommand(scriptPath: string): string { function getManagedScript(target: 'local' | 'posix' = 'local'): string { if (target === 'local' && process.platform === 'win32') { - return [ - '@echo off', - 'setlocal', - 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', - ...buildWindowsHookEnvironmentGuardLines(), - 'set "ORCA_GROK_HOME=%GROK_HOME%"', - `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, - // Why: a trailing backslash escapes curl's closing argv quote on Windows, - // merging the payload option into grokHome and dropping the hook body. - 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', - WINDOWS_GROK_HOOK_POST_COMMAND, - 'exit /b 0', - ...buildWindowsHookStdinDrainEpilogue(), - '' - ].join('\r\n') + return buildWindowsGrokHookScript() } return [ diff --git a/src/main/grok/windows-grok-hook-script.ts b/src/main/grok/windows-grok-hook-script.ts new file mode 100644 index 00000000000..fb396dfafbc --- /dev/null +++ b/src/main/grok/windows-grok-hook-script.ts @@ -0,0 +1,45 @@ +import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils' +import { + buildWindowsHookEnvironmentGuardLines, + buildWindowsHookStdinDrainEpilogue +} from '../agent-hooks/hook-stdin-contract' + +/** Matches the envelope length cap used by the POSIX grok-hook branch. */ +export const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096 + +const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok', [ + // Why: attach grokHome before payload@- without string-replacing the shared template. + ' --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^' +]) + +/** + * Windows `grok-hook.cmd` body. + * + * Why (#9358 / #9941): cmd expands `%VAR:~n,m%` at parse time. When `GROK_HOME` + * is unset (the default outside an Orca-managed terminal), length/trailing + * guards become a syntax error and every Grok hook event fails with exit 255. + * + * - Guard substring ops behind `if defined` + goto (not a parenthesized block). + * - Reject oversized values before copying them onto a cmd input line. + */ +export function buildWindowsGrokHookScript(): string { + return [ + '@echo off', + 'setlocal', + 'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul', + ...buildWindowsHookEnvironmentGuardLines(), + 'set "ORCA_GROK_HOME="', + 'if not defined GROK_HOME goto :orca_grok_home_ready', + `if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" goto :orca_grok_home_ready`, + 'set "ORCA_GROK_HOME=%GROK_HOME%"', + 'if not defined ORCA_GROK_HOME goto :orca_grok_home_ready', + 'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."', + // Why: the trailing-backslash safety sentinel counts toward the relay envelope. + `if not "%ORCA_GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`, + ':orca_grok_home_ready', + WINDOWS_GROK_HOOK_POST_COMMAND, + 'exit /b 0', + ...buildWindowsHookStdinDrainEpilogue(), + '' + ].join('\r\n') +} diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 62448ffd517..e4f50a62501 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -198,7 +198,6 @@ import { } from '../codex/codex-pane-account-registry' import { resolveCodexPaneLaunchAccount } from '../codex/codex-pane-launch-account' import { getSystemCodexHomePath } from '../codex/codex-home-paths' -import { isCodexSystemDefaultRealHomeEnabled } from '../codex/codex-real-home-flag' import { environmentCodexHomeOverrideContextsEqual, getCustomCodexHomeOverrideForLaunch, @@ -1117,10 +1116,7 @@ function shouldStripInheritedOrcaCodexHome(args: { settings: GlobalSettings | undefined }): boolean { return ( - args.target.runtime === 'host' && - args.selectedCodexHomePath === null && - !args.skipCodexHomeEnv && - isCodexSystemDefaultRealHomeEnabled() + args.target.runtime === 'host' && args.selectedCodexHomePath === null && !args.skipCodexHomeEnv ) } diff --git a/src/shared/native-chat-image-transcript-markers.test.ts b/src/shared/native-chat-image-transcript-markers.test.ts index 58d6d0d16c5..e4856605a74 100644 --- a/src/shared/native-chat-image-transcript-markers.test.ts +++ b/src/shared/native-chat-image-transcript-markers.test.ts @@ -35,6 +35,52 @@ describe('normalizeImageTranscriptMessages', () => { ]) }) + it('folds every source and strips every prompt marker for a multi-image send', () => { + const out = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/a.png]'), + userText('b', '[Image: source: /tmp/b.png]'), + userText('c', '[Image: source: /tmp/c.png]'), + userText('prompt', '[Image #1] [Image #2] [Image #3] compare these') + ]) + + expect(out).toHaveLength(1) + expect(out[0]).toMatchObject({ id: 'prompt' }) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'image-ref', path: '/tmp/b.png' }, + { type: 'image-ref', path: '/tmp/c.png' }, + { type: 'text', text: 'compare these' } + ]) + }) + + it('keeps all image refs when a multi-image send has no caption', () => { + const out = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/a.png]'), + userText('b', '[Image: source: /tmp/b.png]'), + userText('prompt', '[Image #1] [Image #2]') + ]) + + expect(out).toHaveLength(1) + expect(out[0]!.blocks).toEqual([ + { type: 'image-ref', path: '/tmp/a.png' }, + { type: 'image-ref', path: '/tmp/b.png' } + ]) + }) + + it('preserves adjacent standalone image turns without a prompt marker', () => { + const out = normalizeImageTranscriptMessages([ + userText('a', '[Image: source: /tmp/a.png]'), + userText('b', '[Image: source: /tmp/b.png]') + ]) + + expect(out).toHaveLength(2) + expect(out.map((message) => message.id)).toEqual(['a', 'b']) + expect(out.map((message) => message.blocks)).toEqual([ + [{ type: 'image-ref', path: '/tmp/a.png' }], + [{ type: 'image-ref', path: '/tmp/b.png' }] + ]) + }) + it('leaves ordinary user text untouched', () => { const out = normalizeImageTranscriptMessages([userText('a', 'how about this')]) expect(out[0]!.blocks).toEqual([{ type: 'text', text: 'how about this' }]) diff --git a/src/shared/native-chat-image-transcript-markers.ts b/src/shared/native-chat-image-transcript-markers.ts index 3f8f90f8cdb..9643cf518e0 100644 --- a/src/shared/native-chat-image-transcript-markers.ts +++ b/src/shared/native-chat-image-transcript-markers.ts @@ -1,7 +1,7 @@ import { isTextBlock, type NativeChatBlock, type NativeChatMessage } from './native-chat-types' const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/ -const IMAGE_PROMPT_MARKER = /^\[Image #\d+\]\s*/ +const IMAGE_PROMPT_MARKERS = /^(?:\[Image #\d+\]\s*)+/ function soleText(message: NativeChatMessage): string | null { return message.blocks.length === 1 && isTextBlock(message.blocks[0]) @@ -14,10 +14,12 @@ export function imageSourcePathFromText(text: string): string | null { } export function stripImagePromptMarker(text: string): string { - return text.replace(IMAGE_PROMPT_MARKER, '') + return text.replace(IMAGE_PROMPT_MARKERS, '') } -function stripFirstImagePromptMarker(blocks: readonly NativeChatBlock[]): NativeChatBlock[] { +function stripImagePromptMarkersFromFirstText( + blocks: readonly NativeChatBlock[] +): NativeChatBlock[] { let stripped = false const next: NativeChatBlock[] = [] for (const block of blocks) { @@ -36,13 +38,11 @@ function stripFirstImagePromptMarker(blocks: readonly NativeChatBlock[]): Native function imagePromptMarkerStartsMessage(message: NativeChatMessage): boolean { const firstText = message.blocks.find(isTextBlock) - return firstText ? IMAGE_PROMPT_MARKER.test(firstText.text) : false + return firstText ? IMAGE_PROMPT_MARKERS.test(firstText.text) : false } -/** Claude records an attached image as two user transcript turns: - * `[Image: source: /path]` and then `[Image #1] prompt`. Merge them back into - * one native turn so the UI keeps the same chip+text shape as the optimistic - * send and does not show raw TUI marker text after a view remount. */ +/** Claude records image paths as source turns followed by one marker-prefixed + * prompt. Merge the whole run back into one native user turn. */ export function normalizeImageTranscriptMessages( messages: readonly NativeChatMessage[] ): NativeChatMessage[] { @@ -54,27 +54,34 @@ export function normalizeImageTranscriptMessages( continue } const imagePath = imageSourcePathFromText(soleText(message) ?? '') - const next = messages[index + 1] - if ( - imagePath && - next?.role === 'user' && - next.source === message.source && - imagePromptMarkerStartsMessage(next) - ) { - normalized.push({ - ...next, - blocks: [ - { type: 'image-ref', path: imagePath }, - ...stripFirstImagePromptMarker(next.blocks) - ] - }) - index += 1 - continue - } - // A lone `[Image: source: /path]` turn (no following `[Image #1]` prompt — - // e.g. an image sent with no caption) still renders as an image chip rather - // than the raw marker text. if (imagePath) { + const imagePaths = [imagePath] + let nextIndex = index + 1 + while (nextIndex < messages.length) { + const candidate = messages[nextIndex]! + const candidatePath = imageSourcePathFromText(soleText(candidate) ?? '') + if (candidate.role !== 'user' || candidate.source !== message.source || !candidatePath) { + break + } + imagePaths.push(candidatePath) + nextIndex += 1 + } + const prompt = messages[nextIndex] + if ( + prompt?.role === 'user' && + prompt.source === message.source && + imagePromptMarkerStartsMessage(prompt) + ) { + normalized.push({ + ...prompt, + blocks: [ + ...imagePaths.map((path) => ({ type: 'image-ref' as const, path })), + ...stripImagePromptMarkersFromFirstText(prompt.blocks) + ] + }) + index = nextIndex + continue + } normalized.push({ ...message, blocks: [{ type: 'image-ref', path: imagePath }] @@ -83,7 +90,7 @@ export function normalizeImageTranscriptMessages( } normalized.push({ ...message, - blocks: stripFirstImagePromptMarker(message.blocks) + blocks: stripImagePromptMarkersFromFirstText(message.blocks) }) } return normalized diff --git a/tests/e2e/electron-home-isolation.spec.ts b/tests/e2e/electron-home-isolation.spec.ts index 79af5114661..65aa1b7dc10 100644 --- a/tests/e2e/electron-home-isolation.spec.ts +++ b/tests/e2e/electron-home-isolation.spec.ts @@ -14,12 +14,13 @@ async function readElectronHomeState(electronApp: ElectronApplication) { home: process.env.HOME, userProfile: process.env.USERPROFILE, codexHome: process.env.CODEX_HOME, - orcaCodexHome: process.env.ORCA_CODEX_HOME, - realHomeFlag: process.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME + orcaCodexHome: process.env.ORCA_CODEX_HOME } }) } +// Codex always routes to the real home now, so this single case covers both the +// HOME boundary and that real-home routing lands inside the disposable profile. test('isolates Electron and Codex from the developer home by default', async ({ electronApp }) => { const state = await readElectronHomeState(electronApp) const expectedHome = path.join(state.userDataDir!, 'home') @@ -30,17 +31,4 @@ test('isolates Electron and Codex from the developer home by default', async ({ expect(state.userProfile).toBe(expectedHome) expect(state.codexHome).toBeUndefined() expect(state.orcaCodexHome).toBeUndefined() - expect(state.realHomeFlag).toBe('0') -}) - -test.describe('sandboxed real-home routing', () => { - test.use({ codexRealHomeEnabled: true }) - - test('keeps flag-ON routing inside the disposable home', async ({ electronApp }) => { - const state = await readElectronHomeState(electronApp) - - expect(state.appHome).toBe(path.join(state.userDataDir!, 'home')) - expect(state.nodeHome).toBe(path.join(state.userDataDir!, 'home')) - expect(state.realHomeFlag).toBe('1') - }) }) diff --git a/tests/e2e/headless-serve-desktop-activation.spec.ts b/tests/e2e/headless-serve-desktop-activation.spec.ts index e19e0dabf9f..1d4b4a0f82c 100644 --- a/tests/e2e/headless-serve-desktop-activation.spec.ts +++ b/tests/e2e/headless-serve-desktop-activation.spec.ts @@ -53,8 +53,7 @@ function createHeadlessLaunchIsolation(userDataDir: string): ElectronHomeIsolati ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1' }, extraEnv: {}, - userDataDir, - codexRealHomeEnabled: false + userDataDir }) } diff --git a/tests/e2e/helpers/computer-cli-driver.ts b/tests/e2e/helpers/computer-cli-driver.ts index 7e9d8b1a6ec..a96b55d5f2a 100644 --- a/tests/e2e/helpers/computer-cli-driver.ts +++ b/tests/e2e/helpers/computer-cli-driver.ts @@ -174,8 +174,7 @@ async function createComputerE2ERuntimeEnv(): Promise { inheritedEnv, launchEnv: {}, extraEnv: {}, - userDataDir, - codexRealHomeEnabled: false + userDataDir }) return { ...isolation.env, diff --git a/tests/e2e/helpers/electron-home-isolation.ts b/tests/e2e/helpers/electron-home-isolation.ts index fd526a36efc..e81cbae1907 100644 --- a/tests/e2e/helpers/electron-home-isolation.ts +++ b/tests/e2e/helpers/electron-home-isolation.ts @@ -9,7 +9,6 @@ const RESTRICTED_ENV_KEYS = new Set([ 'HOMEPATH', 'CODEX_HOME', 'ORCA_CODEX_HOME', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_E2E_USER_DATA_DIR', 'ORCA_E2E_HOME_DIR', 'ZDOTDIR', @@ -23,7 +22,6 @@ type ElectronHomeIsolationOptions = { launchEnv: NodeJS.ProcessEnv extraEnv: Record userDataDir: string - codexRealHomeEnabled: boolean realHome?: string } @@ -50,9 +48,7 @@ function assertOverlayDoesNotReplaceIsolation( RESTRICTED_ENV_KEYS.has(key.toUpperCase()) ) if (restrictedKey) { - throw new Error( - `${overlayName}.${restrictedKey} cannot override the E2E home boundary; use codexRealHomeEnabled for sandboxed real-home coverage` - ) + throw new Error(`${overlayName}.${restrictedKey} cannot override the E2E home boundary`) } } @@ -67,7 +63,6 @@ export function createElectronHomeIsolation({ launchEnv, extraEnv, userDataDir, - codexRealHomeEnabled, realHome = os.homedir() }: ElectronHomeIsolationOptions): ElectronHomeIsolation { assertOverlayDoesNotReplaceIsolation(launchEnv, 'launchEnv') @@ -95,8 +90,7 @@ export function createElectronHomeIsolation({ HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_USER_DATA_DIR: userDataDir, - ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: codexRealHomeEnabled ? '1' : '0' + ORCA_E2E_HOME_DIR: isolatedHome } } } diff --git a/tests/e2e/helpers/electron-home-isolation.unit.test.ts b/tests/e2e/helpers/electron-home-isolation.unit.test.ts index ab1ed9901fd..b84770bcdeb 100644 --- a/tests/e2e/helpers/electron-home-isolation.unit.test.ts +++ b/tests/e2e/helpers/electron-home-isolation.unit.test.ts @@ -37,7 +37,6 @@ describe('createElectronHomeIsolation', () => { launchEnv: { TEST_TOKEN: 'safe' }, extraEnv: { EXTRA_TEST_FLAG: '1' }, userDataDir, - codexRealHomeEnabled: false, realHome: '/real/home' }) @@ -51,12 +50,16 @@ describe('createElectronHomeIsolation', () => { EXTRA_TEST_FLAG: '1', HOME: canonicalHome, USERPROFILE: canonicalHome, - ORCA_E2E_USER_DATA_DIR: userDataDir, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0' + ORCA_E2E_USER_DATA_DIR: userDataDir }) expect(isolation.env.CODEX_HOME).toBeUndefined() expect(isolation.env.ORCA_CODEX_HOME).toBeUndefined() expect(isolation.env.ZDOTDIR).toBeUndefined() + // Codex always routes to the resolved home, so the post-launch guard must + // accept the boundary this env produces. + expect(() => + assertElectronResolvedIsolatedHome(isolation.isolatedHome, isolation) + ).not.toThrow() }) it('rejects generic fixture overlays that could escape the boundary', () => { @@ -66,7 +69,6 @@ describe('createElectronHomeIsolation', () => { launchEnv: { CODEX_HOME: '/unsafe' }, extraEnv: {}, userDataDir: createUserDataDir(), - codexRealHomeEnabled: false, realHome: '/real/home' }) ).toThrow(/launchEnv\.CODEX_HOME/) @@ -77,28 +79,11 @@ describe('createElectronHomeIsolation', () => { launchEnv: {}, extraEnv: { ORCA_E2E_USER_DATA_DIR: '/unsafe' }, userDataDir: createUserDataDir(), - codexRealHomeEnabled: false, realHome: '/real/home' }) ).toThrow(/orcaAppExtraEnv\.ORCA_E2E_USER_DATA_DIR/) }) - it('keeps real-home routing inside the disposable home when explicitly enabled', () => { - const isolation = createElectronHomeIsolation({ - inheritedEnv: {}, - launchEnv: {}, - extraEnv: {}, - userDataDir: createUserDataDir(), - codexRealHomeEnabled: true, - realHome: '/real/home' - }) - - expect(isolation.env.ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME).toBe('1') - expect(() => - assertElectronResolvedIsolatedHome(isolation.isolatedHome, isolation) - ).not.toThrow() - }) - it('compares Windows home paths case-insensitively', () => { expect(areSameHomePath('C:\\Users\\Alice', 'c:\\users\\alice', 'win32')).toBe(true) }) diff --git a/tests/e2e/helpers/headless-paired-runtime-host.ts b/tests/e2e/helpers/headless-paired-runtime-host.ts index 9159154b25d..dec59682938 100644 --- a/tests/e2e/helpers/headless-paired-runtime-host.ts +++ b/tests/e2e/helpers/headless-paired-runtime-host.ts @@ -201,8 +201,7 @@ export async function launchHeadlessPairedRuntimeHost(): Promise({ launchEnv, orcaAppExtraEnv, orcaAppExtraArgs, - codexRealHomeEnabled, registerPostElectronShutdownCleanup }, provideFixture, @@ -212,8 +208,7 @@ export const test = base.extend({ inheritedEnv: cleanEnv, launchEnv, extraEnv: orcaAppExtraEnv, - userDataDir, - codexRealHomeEnabled + userDataDir }) // Why: ORCA_E2E_SLOWMO_MS adds a pause between every Playwright action so a // developer running with ORCA_E2E_FORCE_HEADFUL=1 can actually watch what @@ -281,7 +276,6 @@ export const test = base.extend({ launchEnv: [{}, { option: true }], orcaAppExtraEnv: [{}, { option: true }], orcaAppExtraArgs: [[], { option: true }], - codexRealHomeEnabled: [false, { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. diff --git a/tests/e2e/helpers/orca-restart.ts b/tests/e2e/helpers/orca-restart.ts index 609ff1797bc..c7a93f81450 100644 --- a/tests/e2e/helpers/orca-restart.ts +++ b/tests/e2e/helpers/orca-restart.ts @@ -120,8 +120,7 @@ function createRestartLaunchIsolation( ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) }, extraEnv: {}, - userDataDir, - codexRealHomeEnabled: false + userDataDir }) } diff --git a/tests/e2e/helpers/paired-electron-client.ts b/tests/e2e/helpers/paired-electron-client.ts index c33764a243a..07bb467780f 100644 --- a/tests/e2e/helpers/paired-electron-client.ts +++ b/tests/e2e/helpers/paired-electron-client.ts @@ -155,8 +155,7 @@ export async function launchPairedElectronClient( inheritedEnv: cleanEnv, launchEnv: {}, extraEnv: {}, - userDataDir, - codexRealHomeEnabled: false + userDataDir }) const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const app = await electron.launch({ diff --git a/tests/tools/benchmarks/daemon-coldstart-bench.mjs b/tests/tools/benchmarks/daemon-coldstart-bench.mjs index eebc02a3dba..5e234d8b6a2 100644 --- a/tests/tools/benchmarks/daemon-coldstart-bench.mjs +++ b/tests/tools/benchmarks/daemon-coldstart-bench.mjs @@ -177,7 +177,6 @@ function runIteration({ exe, fixtureDir, timeoutMs, lingerMs }) { HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ORCA_E2E_HEADLESS: '1' } delete env.CODEX_HOME diff --git a/tests/tools/benchmarks/main-thread-jank-bench.mjs b/tests/tools/benchmarks/main-thread-jank-bench.mjs index 914c72a67f8..a64eecaf985 100644 --- a/tests/tools/benchmarks/main-thread-jank-bench.mjs +++ b/tests/tools/benchmarks/main-thread-jank-bench.mjs @@ -289,8 +289,7 @@ async function main() { ORCA_E2E_USER_DATA_DIR: fixtureDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0' + ORCA_E2E_HOME_DIR: isolatedHome } delete env.CODEX_HOME delete env.ORCA_CODEX_HOME diff --git a/tests/tools/benchmarks/startup-time-bench.mjs b/tests/tools/benchmarks/startup-time-bench.mjs index 3e64ee4a8a1..29363432c7a 100644 --- a/tests/tools/benchmarks/startup-time-bench.mjs +++ b/tests/tools/benchmarks/startup-time-bench.mjs @@ -364,7 +364,6 @@ function buildLaunchEnvironment({ fixtureDir, githubRepos, ghShimDir }) { HOME: isolatedHome, USERPROFILE: isolatedHome, ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0', ORCA_E2E_HEADLESS: '1' } delete env.CODEX_HOME diff --git a/tests/tools/win-update-e2e/app-driver.mjs b/tests/tools/win-update-e2e/app-driver.mjs index 72603fba921..d95e7f2c342 100644 --- a/tests/tools/win-update-e2e/app-driver.mjs +++ b/tests/tools/win-update-e2e/app-driver.mjs @@ -35,7 +35,6 @@ const RESTRICTED_E2E_ENV_KEYS = new Set([ 'USERPROFILE', 'CODEX_HOME', 'ORCA_CODEX_HOME', - 'ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME', 'ORCA_E2E_HOME_DIR', 'ORCA_E2E_USER_DATA_DIR' ]) @@ -92,8 +91,7 @@ export async function launchInstalledApp({ ORCA_E2E_USER_DATA_DIR: userDataDir, HOME: isolatedHome, USERPROFILE: isolatedHome, - ORCA_E2E_HOME_DIR: isolatedHome, - ORCA_CODEX_SYSTEM_DEFAULT_REAL_HOME: '0' + ORCA_E2E_HOME_DIR: isolatedHome } }) // If firstWindow times out (the launched main never shows a window), the