mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
Merge branch 'main' of https://github.com/stablyai/orca into feature-extend-ssh-host-aware-agent-session-hist
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+16
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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 [
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -174,8 +174,7 @@ async function createComputerE2ERuntimeEnv(): Promise<NodeJS.ProcessEnv> {
|
||||
inheritedEnv,
|
||||
launchEnv: {},
|
||||
extraEnv: {},
|
||||
userDataDir,
|
||||
codexRealHomeEnabled: false
|
||||
userDataDir
|
||||
})
|
||||
return {
|
||||
...isolation.env,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -120,8 +120,7 @@ function createRestartLaunchIsolation(
|
||||
...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' })
|
||||
},
|
||||
extraEnv: {},
|
||||
userDataDir,
|
||||
codexRealHomeEnabled: false
|
||||
userDataDir
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user