Merge branch 'main' of https://github.com/stablyai/orca into feature-extend-ssh-host-aware-agent-session-hist

This commit is contained in:
Jinjing
2026-08-05 13:49:04 -07:00
78 changed files with 2814 additions and 3114 deletions
@@ -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')
})
@@ -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 <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--system-default-real-home on|off] [--report <path>]'
'Usage: node config/scripts/run-codex-real-account-validation.mjs [--scenario mixed|managed-only|codex-lb] [--config-template <path>] [--temp-parent <dir>] [--skip-build] [--dry-run] [--close-after-launch] [--keep] [--lane-aware-containment] [--report <path>]'
)
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()
@@ -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<string, string | undefined>
}>(
`
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 }
@@ -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' })
}
})
@@ -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' }
: {})
@@ -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',
@@ -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),
+1
View File
@@ -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",
+1
View File
@@ -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'
@@ -45,7 +45,7 @@ describe('mobile accounts route', () => {
navigateToHostStackRoute(
harness.navigation,
{ push },
{ push, replace: vi.fn() },
'host/one',
mobileAccountsRouteTarget('host/one')
)
@@ -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,
@@ -35,8 +35,14 @@ export type HostStackRootNavigation = {
export type HostStackHostRoute = `/h/${string}`
export type HostStackRouteHref = Readonly<{
pathname: `/h/${string}`
params: Readonly<Record<string, string>>
}>
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()
@@ -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()
@@ -47,6 +47,7 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
nativeChatStreamLive: tick.streamLive ?? false,
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
chatPending: [],
chatImagePreviewsByMessageId: {},
chatComposerText: '',
setChatComposerText: vi.fn()
} as unknown as MobileNativeChatController
@@ -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')}
@@ -50,8 +50,13 @@ vi.mock('./MobileAgentWorkingIndicator', () => ({
vi.mock('./MobileNativeChatComposer', async () => {
const React = await import('react')
return {
MobileNativeChatComposer: (props: { onSend: (text: string) => Promise<boolean> }) =>
MobileNativeChatComposer: (props: {
onSend: (text: string) => Promise<boolean>
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()
}
})
})
+20 -10
View File
@@ -33,6 +33,8 @@ import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
const INPUT_LOCK_SETTLE_MS = 600
/** Why the composer input is locked: the transport is disconnected, or the
* terminal subscription has not acknowledged its input lease yet. */
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
@@ -59,6 +61,9 @@ type Props = {
/** Optimistic queued sends (owned by the route so they survive view switches). */
/** Optimistic user echoes, including any ridden-along image preview URIs. */
pending: MobileNativeChatPendingItem[]
/** Local photo URIs retained when the authoritative transcript replaces an
* optimistic image bubble. */
imagePreviewsByMessageId?: Record<string, string[]>
/** Controlled composer text (owned by the route so dictation can write to it). */
composerText: string
onComposerTextChange: (text: string) => void
@@ -123,6 +128,7 @@ export function MobileNativeChatView({
onLoadEarlier,
onSend,
pending,
imagePreviewsByMessageId,
composerText,
onComposerTextChange,
onAttachImage,
@@ -175,8 +181,14 @@ export function MobileNativeChatView({
// route-owned optimistic queued messages. Memoize on the same deps so the
// downstream autoscroll effects/`renderItem` keep referential stability.
const { data } = useMemo(
() => buildMobileNativeChatTransientData({ folded, streaming, pending }),
[folded, streaming, pending]
() =>
buildMobileNativeChatTransientData({
folded,
streaming,
pending,
imagePreviewsByMessageId
}),
[folded, streaming, pending, imagePreviewsByMessageId]
)
// Follow the tail as the conversation grows and keep the newest message above
@@ -250,20 +262,18 @@ export function MobileNativeChatView({
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
const showLoading = status === 'loading' && messages.length === 0
// Composer-lock flicker guard: on a remote link, brief connState blips or lease
// hand-offs would otherwise toggle the lock placeholder on and off. Only surface
// a lock once it has held ~600ms; drop it instantly so unlocking stays snappy.
// A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled.
const rawLockReason = inputLockReason ?? null
const rawLockHeld = rawLockReason !== null
const [lockHeld, setLockHeld] = useState(false)
useEffect(() => {
if (rawLockReason === null) {
setLockHeld(false)
if (rawLockHeld === lockHeld) {
return
}
const timer = setTimeout(() => setLockHeld(true), 600)
const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS)
return () => clearTimeout(timer)
}, [rawLockReason])
const lockReason = lockHeld ? rawLockReason : null
}, [lockHeld, rawLockHeld])
const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null
return (
<View style={[styles.root, { paddingBottom: bottomPad }]}>
@@ -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<typeof import('expo-image-picker').requestMediaLibraryPermissionsAsync>
>
const denied = { granted: false } as typeof granted
async function collectImages(
images: AsyncIterable<PickedMobileImage>
): Promise<PickedMobileImage[]> {
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', {
@@ -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<PickedMobileImage | null> {
): AsyncGenerator<PickedMobileImage> {
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<PickedMobileImage | null> {
): AsyncGenerator<PickedMobileImage> {
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<PickedMobileImage> {
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<PickedMobileImage | null> {
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<PickedMobileImage> {
return pickMobileImagesWithMode(source, true, deps)
}
@@ -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'] }
})
})
})
@@ -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<string, Record<string, string[]>>,
sessionKey: string,
landed: readonly LandedImagePreviewEcho[]
): Record<string, Record<string, string[]>> {
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<string, Record<string, string[]>>,
sessionKey: string,
messages: readonly NativeChatMessage[]
): Record<string, Record<string, string[]>> {
const sessionPreviews = previous[sessionKey]
if (!sessionPreviews) {
return previous
}
const messageIndexById = new Map(messages.map((message, index) => [message.id, index]))
let nextSession: Record<string, string[]> | 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<string>()
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[]
@@ -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<RpcClient, 'sendRequest'> & {
calls: { method: string; params: unknown }[]
} {
@@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
}
}
describe('uploadMobileNativeChatImage', () => {
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)
@@ -13,34 +13,67 @@ export type PendingNativeChatImage = {
readonly previewUri: string
}
export type UploadNativeChatImageDeps = {
export function appendPendingNativeChatImages(
current: readonly PendingNativeChatImage[],
uploaded: readonly Omit<PendingNativeChatImage, 'id'>[],
idCounter: { current: number }
): PendingNativeChatImage[] {
return [
...current,
...uploaded.map((image) => {
idCounter.current += 1
return { id: `img-${idCounter.current}`, ...image }
})
]
}
export type UploadNativeChatImagesDeps = {
readonly client: Pick<RpcClient, 'sendRequest'>
readonly getConnectionId: () => Promise<string | null>
// Injected so this module stays free of expo/react-native imports (unit-testable).
readonly pickImage: (source: MobileImageSource) => Promise<PickedMobileImage | null>
readonly pickImages: (
source: MobileImageSource
) =>
| Iterable<PickedMobileImage>
| AsyncIterable<PickedMobileImage>
| Promise<Iterable<PickedMobileImage> | AsyncIterable<PickedMobileImage>>
// 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<PendingNativeChatImage, 'id'>) => 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<Omit<PendingNativeChatImage, 'id'> | null> {
const picked = await pickImage(source)
if (!picked) {
return null
{
client,
getConnectionId,
pickImages,
onUploadStart,
onImageUploaded
}: UploadNativeChatImagesDeps
): Promise<Omit<PendingNativeChatImage, 'id'>[]> {
const picked = await pickImages(source)
const uploaded: Omit<PendingNativeChatImage, 'id'>[] = []
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 <Image> 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 <Image> renders both).
const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}`
return { path, previewUri }
return uploaded
}
@@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
describe('pasteMobileNativeChatImagePaths', () => {
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 () => {
@@ -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<string, MobileNativeChatPendingMessage[]>
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<string>
): 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
}
@@ -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.
@@ -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<string, string[]>
}): { 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 }
}
@@ -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()
@@ -40,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
composerText: '',
setComposerText: vi.fn(),
pending: [],
imagePreviewsByMessageId: {},
captureSendOrigin,
readSeededLaunchDraft: () => null,
readSeededLaunchDraftSeed: () => null,
@@ -50,6 +50,7 @@ export type MobileNativeChatController = {
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
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,
@@ -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 })
})
})
@@ -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<string, string[]> = {}
// 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<SetStateAction<string>>
pending: MobileNativeChatPendingMessage[]
/** Phone-local previews rebound to the transcript message that replaced the
* optimistic echo, keyed by authoritative message id. */
imagePreviewsByMessageId: Record<string, string[]>
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<string, MobileNativeChatPendingMessage[]>
>({})
const [pendingWaitingForSession, setPendingWaitingForSession] = useState<
Record<string, MobileNativeChatPendingMessage[]>
>({})
const [imagePreviewsBySession, setImagePreviewsBySession] = useState<
Record<string, Record<string, string[]>>
>({})
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<string, number>()
@@ -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,
@@ -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<void> | 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<RpcResponse>((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<void> | null = null
await act(async () => {
attach = hook!.attachImage('library')
@@ -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<boolean>
}
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<PendingNativeChatImage, 'id'>[] = []
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)
}
}
}
},
[
@@ -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<RpcClient, 'sendRequest'> {
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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
.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<RpcClient['sendRequest']>()
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<RpcClient['sendRequest']>().mockImplementation(async () => {
current = false
return success({ tabs: [] })
})
await expect(
revealMobileSourceControlSessionDiff({
...options(sendRequest),
isCurrent: () => current
})
).resolves.toBe('cancelled')
expect(sendRequest).toHaveBeenCalledOnce()
})
})
@@ -0,0 +1,143 @@
import type { RpcClient } from '../transport/rpc-client'
import { activateMobileSessionTab } from '../session/mobile-session-tab-activation'
type ActivationClient = Pick<RpcClient, 'sendRequest'>
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<MobileSourceControlSessionDiffRevealResult> {
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<SessionFileTabCandidate | null> {
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<boolean> {
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<string, unknown> {
return typeof value === 'object' && value !== null
}
async function waitForDelay(delayMs: number): Promise<void> {
if (delayMs === 0) {
return
}
await new Promise<void>((resolve) => setTimeout(resolve, delayMs))
}
@@ -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
+29 -31
View File
@@ -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'
)
@@ -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()
@@ -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()
})
})
@@ -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<MobileRelayPairingRecoveryResult> | 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'
}
+1
View File
@@ -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",
+16
View File
@@ -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
+9 -1
View File
@@ -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')
}
@@ -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'
})
})
})
@@ -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<string>
ancestors: Set<string>
descendantRoots: Set<string>
} {
const exact = new Set<string>()
const ancestors = new Set<string>()
const descendantRoots = new Set<string>()
const seen = new Set<string>()
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<typeof importedDomainScopes>
): 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, 'set'>,
cookies: readonly Cookie[]
): Promise<void> {
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<Cookies, 'get' | 'remove' | 'set'>,
importedDomains: readonly string[]
): Promise<Cookie[]> {
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
}
@@ -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<typeof vi.fn>
let cookiesRemoveMock: ReturnType<typeof vi.fn>
let cookiesSetMock: ReturnType<typeof vi.fn>
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<typeof vi.fn>
let cookiesSetMock: ReturnType<typeof vi.fn>
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'
}
}
@@ -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),
+12 -8
View File
@@ -160,14 +160,22 @@ function buildExpiredSafariCookie(index: number): Buffer {
describe('importCookiesFromFile', () => {
let tmpDir: string
let cookiesGetMock: ReturnType<typeof vi.fn>
let cookiesRemoveMock: ReturnType<typeof vi.fn>
let cookiesSetMock: ReturnType<typeof vi.fn>
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')
})
})
+128 -30
View File
@@ -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<BrowserCookieImportResult> {
const importDomainCache = new Map<string, boolean>()
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<string>()
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<string, boolean>()
// 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 } : {})
}
@@ -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)
@@ -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'
File diff suppressed because it is too large Load Diff
+55 -295
View File
@@ -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<string, string | null>()
private readonly wslRuntimeHomePathByDistro = new Map<string, string>()
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/<id>/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/<id>/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,
@@ -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
+25 -72
View File
@@ -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<GlobalSettings> & {
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> = {}): 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)
})
+12 -16
View File
@@ -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)
@@ -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)
})
})
-35
View File
@@ -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
}
+183 -4
View File
@@ -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<WindowsGrokHookRun> {
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<void>((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<Omit<WindowsGrokHookRun, 'request'>>((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<void>((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
+5 -27
View File
@@ -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 [
+45
View File
@@ -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')
}
+1 -5
View File
@@ -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
)
}
@@ -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' }])
@@ -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
+3 -15
View File
@@ -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')
})
})
@@ -53,8 +53,7 @@ function createHeadlessLaunchIsolation(userDataDir: string): ElectronHomeIsolati
ORCA_E2E_ENFORCE_SINGLE_INSTANCE_LOCK: '1'
},
extraEnv: {},
userDataDir,
codexRealHomeEnabled: false
userDataDir
})
}
+1 -2
View File
@@ -174,8 +174,7 @@ async function createComputerE2ERuntimeEnv(): Promise<NodeJS.ProcessEnv> {
inheritedEnv,
launchEnv: {},
extraEnv: {},
userDataDir,
codexRealHomeEnabled: false
userDataDir
})
return {
...isolation.env,
+2 -8
View File
@@ -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<string, string>
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
}
}
}
@@ -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)
})
@@ -201,8 +201,7 @@ export async function launchHeadlessPairedRuntimeHost(): Promise<HeadlessPairedR
ORCA_E2E_HEADLESS: '1'
},
extraEnv: {},
userDataDir,
codexRealHomeEnabled: false
userDataDir
})
const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js')
app = await electron.launch({
+1 -7
View File
@@ -55,9 +55,6 @@ type OrcaTestFixtures = {
// memory benchmarks). Prepended before the main entry so Electron forwards
// them to Chromium without affecting other specs' launches.
orcaAppExtraArgs: string[]
// Why: real-home E2E must still resolve inside the disposable fixture HOME.
// Generic env overlays cannot opt out of that data-safety boundary.
codexRealHomeEnabled: boolean
// Why: a few IPC repro specs need to launch the Electron app with a scoped
// PATH/token environment. Keep this fixture-owned so tests never mutate the
// developer's shell or already-running Orca instance.
@@ -178,7 +175,6 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
launchEnv,
orcaAppExtraEnv,
orcaAppExtraArgs,
codexRealHomeEnabled,
registerPostElectronShutdownCleanup
},
provideFixture,
@@ -212,8 +208,7 @@ export const test = base.extend<OrcaTestFixtures, OrcaWorkerFixtures>({
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<OrcaTestFixtures, OrcaWorkerFixtures>({
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.
+1 -2
View File
@@ -120,8 +120,7 @@ function createRestartLaunchIsolation(
...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
},
extraEnv: {},
userDataDir,
codexRealHomeEnabled: false
userDataDir
})
}
+1 -2
View File
@@ -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({
@@ -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
@@ -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
@@ -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
+1 -3
View File
@@ -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