From 38ba22ecd166e7496087f85e913adf9a116da29c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:51:05 -0700 Subject: [PATCH 1/8] fix(browser): align cookie import safeguards (#12607) * fix(browser): align cookie import safeguards * fix(browser): preserve sessions on failed cookie imports * fix(browser): bound single-label cookie replacement * fix(browser): preserve host-only parent cookies * fix(build): bundle cookie scope parser --- .../electron-vite-output-contract.test.ts | 4 +- electron.vite.config.ts | 1 + package.json | 1 + pnpm-lock.yaml | 16 + .../browser-cookie-import-policy.test.ts | 149 ++++++++++ .../browser/browser-cookie-import-policy.ts | 201 +++++++++++++ .../browser-cookie-import-replacement.test.ts | 278 ++++++++++++++++++ .../browser-cookie-import-test-database.ts | 3 +- .../browser/browser-cookie-import.test.ts | 20 +- src/main/browser/browser-cookie-import.ts | 158 ++++++++-- 10 files changed, 791 insertions(+), 40 deletions(-) create mode 100644 src/main/browser/browser-cookie-import-policy.test.ts create mode 100644 src/main/browser/browser-cookie-import-policy.ts create mode 100644 src/main/browser/browser-cookie-import-replacement.test.ts 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/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/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/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 } : {}) } From 4c49989c2eaf474a0d635acf818cbdbdf53828a5 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:57:02 -0700 Subject: [PATCH 2/8] refactor(codex): delete the unreachable managed shared-mirror lane (#12614) PR 9501 shipped real-home routing for the host system default, and the env override that could turn it back off was never a shipped control. The managed-account half of the shared runtime mirror has been unreachable since: every host account routes to its own self-contained CODEX_HOME before that code runs. Delete the flag module and its env plumbing plus the managed branch of syncForCurrentSelection and the six helpers only it called. The three lanes that still use the shared mirror -- Windows, a custom CODEX_HOME, and a hook-lane gate that reports unusable -- are untouched, as are every legacy migration and the WSL read-back helpers. --- .../run-codex-real-account-validation.mjs | 25 +- .../run-codex-real-account-validation.test.ts | 20 - config/scripts/run-idle-cpu-benchmark.mjs | 1 - .../serve-headless-fresh-profile-pairing.mjs | 1 - .../verify-linux-wayland-gpu-sandbox.mjs | 1 - .../electron-dev-session.mjs | 1 - .../runtime-home-mirrored-status-home.test.ts | 11 +- ...home-service-per-account-migration.test.ts | 1 - .../runtime-home-service.test.ts | 2268 +---------------- .../codex-accounts/runtime-home-service.ts | 350 +-- ...ime-home-windows-profile-ownership.test.ts | 3 - src/main/codex-accounts/service.test.ts | 97 +- src/main/codex-accounts/service.ts | 28 +- src/main/codex/codex-real-home-flag.test.ts | 43 - src/main/codex/codex-real-home-flag.ts | 35 - src/main/ipc/pty.ts | 6 +- tests/e2e/electron-home-isolation.spec.ts | 18 +- .../headless-serve-desktop-activation.spec.ts | 3 +- tests/e2e/helpers/computer-cli-driver.ts | 3 +- tests/e2e/helpers/electron-home-isolation.ts | 10 +- .../electron-home-isolation.unit.test.ts | 27 +- .../helpers/headless-paired-runtime-host.ts | 3 +- tests/e2e/helpers/orca-app.ts | 8 +- tests/e2e/helpers/orca-restart.ts | 3 +- tests/e2e/helpers/paired-electron-client.ts | 3 +- .../benchmarks/daemon-coldstart-bench.mjs | 1 - .../benchmarks/main-thread-jank-bench.mjs | 3 +- tests/tools/benchmarks/startup-time-bench.mjs | 1 - tests/tools/win-update-e2e/app-driver.mjs | 4 +- 29 files changed, 211 insertions(+), 2767 deletions(-) delete mode 100644 src/main/codex/codex-real-home-flag.test.ts delete mode 100644 src/main/codex/codex-real-home-flag.ts 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/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/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/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 From 23238aee0b5980af9611c7b5006ef96c9daae0de Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:13:05 -0700 Subject: [PATCH 3/8] fix(mobile): stop native-chat send button flicker (#12764) * fix(mobile): stop native-chat send button flicker * fix(mobile): keep composer lock rendering pure --- .../src/session/MobileNativeChatView.test.ts | 49 ++++++++++++++++++- mobile/src/session/MobileNativeChatView.tsx | 16 +++--- 2 files changed, 56 insertions(+), 9 deletions(-) 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..48a1fcd31d3 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' @@ -250,20 +252,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 ( From d4dfc35ac4bdacc44fd98703a2cd8e880de0a5c9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:23:29 -0700 Subject: [PATCH 4/8] fix(mobile): preserve multi-image chat attachments (#12639) * fix(mobile): preserve multi-image chat attachments * fix(mobile): use preferred array syntax * fix(mobile): harden multi-image attachment flow * fix(mobile): retain first-send image previews --- .../session/MobileNativeChatOverlay.test.ts | 1 + .../src/session/MobileNativeChatOverlay.tsx | 1 + mobile/src/session/MobileNativeChatView.tsx | 14 +- .../mobile-image-source-picker.test.ts | 68 +++++++- .../src/session/mobile-image-source-picker.ts | 88 +++++++---- ...mobile-native-chat-draft-reconcile.test.ts | 72 +++++++++ .../mobile-native-chat-draft-reconcile.ts | 146 ++++++++++++++++- ...obile-native-chat-image-attachment.test.ts | 104 ++++++++++-- .../mobile-native-chat-image-attachment.ts | 65 ++++++-- .../mobile-native-chat-image-send.test.ts | 12 +- .../mobile-native-chat-pending-echo.ts | 91 +++++++++++ .../mobile-native-chat-render-data.test.ts | 30 ++++ .../session/mobile-native-chat-render-data.ts | 30 +++- .../use-mobile-native-chat-controller.test.ts | 1 + .../use-mobile-native-chat-controller.ts | 3 + .../use-mobile-native-chat-drafts.test.ts | 72 ++++++++- .../session/use-mobile-native-chat-drafts.ts | 148 ++++++++++-------- ...bile-native-chat-image-attachments.test.ts | 52 +++--- ...se-mobile-native-chat-image-attachments.ts | 54 ++++--- ...tive-chat-image-transcript-markers.test.ts | 46 ++++++ .../native-chat-image-transcript-markers.ts | 65 ++++---- 21 files changed, 944 insertions(+), 219 deletions(-) create mode 100644 mobile/src/session/mobile-native-chat-draft-reconcile.test.ts create mode 100644 mobile/src/session/mobile-native-chat-pending-echo.ts 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.tsx b/mobile/src/session/MobileNativeChatView.tsx index 48a1fcd31d3..478d36267a7 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -61,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 @@ -125,6 +128,7 @@ export function MobileNativeChatView({ onLoadEarlier, onSend, pending, + imagePreviewsByMessageId, composerText, onComposerTextChange, onAttachImage, @@ -177,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 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/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/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 From 74ac7049ecfa244e4f0384ccfb555036163033c1 Mon Sep 17 00:00:00 2001 From: Kyou <1129057944@qq.com> Date: Thu, 6 Aug 2026 04:31:26 +0800 Subject: [PATCH 5/8] fix(windows): make managed grok-hook.cmd safe when GROK_HOME is unset (#11782) * fix(windows): make managed grok-hook.cmd safe when GROK_HOME is unset Fixes #9358 and #9941. cmd.exe expands %VAR:~n,m% at parse time. When GROK_HOME is unset (default outside Orca terminals), the generated length/trailing-backslash guards became a syntax error and every Grok hook event failed with exit 255. - Skip substring work when GROK_HOME is undefined (if defined + goto) - Replace if "%x:~-1%"=="\" (itself a quote-parser bug) with findstr - Extract Windows script builder; add template + spawn tests * fix(windows): harden grok-hook GROK_HOME guards and tests Address review on #11782: - Inject grokHome via buildWindowsAgentHookPostCommand extra form lines (no fragile string replace of the shared payload line) - Spawn tests delete GROK_HOME and keep PORT/TOKEN/PANE_KEY set so the GROK_HOME path actually runs before curl * fix(windows): cover Grok hook home boundaries --------- Co-authored-by: OrcaWin --- config/tsconfig.cli.json | 1 + src/main/agent-hooks/installer-utils.ts | 10 +- src/main/grok/hook-service.test.ts | 187 +++++++++++++++++++++- src/main/grok/hook-service.ts | 32 +--- src/main/grok/windows-grok-hook-script.ts | 45 ++++++ 5 files changed, 243 insertions(+), 32 deletions(-) create mode 100644 src/main/grok/windows-grok-hook-script.ts 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/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/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') +} From 885afb55a9daaefe2aa566529f2522c237ff3c09 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:32:58 -0700 Subject: [PATCH 6/8] fix(mobile): open host editor from root navigation (#12766) * fix(mobile): open host editor from root navigation * test(mobile): update task navigation router contract --- .../accounts/mobile-accounts-route.test.ts | 2 +- .../navigation/host-stack-navigation.test.ts | 50 +++++++++++++--- .../src/navigation/host-stack-navigation.ts | 14 +++++ .../notification-route-coordination.test.ts | 7 ++- .../src/session/mobile-session-route.test.ts | 2 +- .../src/tasks/mobile-task-navigation.test.ts | 60 +++++++++---------- .../transport/host-edit-navigation.test.ts | 12 ++-- 7 files changed, 102 insertions(+), 45 deletions(-) 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/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/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() From 7f4570c9a669d6b2c683ecd07e822fb95d32ff56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:36:08 -0700 Subject: [PATCH 7/8] fix(mobile): activate Source Control diff tabs on phones (#12770) * fix(mobile): activate source-control diff tabs on phones * fix(mobile): reveal legacy source control file tabs --- ...mobile-source-control-session-diff.test.ts | 233 ++++++++++++++++++ ...veal-mobile-source-control-session-diff.ts | 143 +++++++++++ .../use-mobile-source-control-openers.ts | 19 +- 3 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 mobile/src/source-control/reveal-mobile-source-control-session-diff.test.ts create mode 100644 mobile/src/source-control/reveal-mobile-source-control-session-diff.ts 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 From be2f9eddd39e668e19f9b0dd5fc3352125de8229 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:45:39 -0700 Subject: [PATCH 8/8] fix(mobile): abandon pairing journals that can no longer reconcile (#12773) --- .../mobile-relay-pairing-recovery.test.ts | 49 +++++++++++++++++++ .../mobile-relay-pairing-recovery.ts | 25 +++++++++- 2 files changed, 73 insertions(+), 1 deletion(-) 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' }